Skip to content

Commit eb5e85f

Browse files
dynamic mcp: harden client catalog discovery
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2a1bd28 commit eb5e85f

17 files changed

Lines changed: 2555 additions & 372 deletions

llm/context.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package llm
55

66
import (
7+
stdcontext "context"
78
"fmt"
89
"strings"
910
"time"
@@ -36,6 +37,13 @@ type Context struct {
3637
// User that is making the request
3738
RequestingUser *model.User
3839

40+
// RequestContext carries the caller's request-scoped context for downstream
41+
// work such as MCP tool discovery. May be nil in tests.
42+
RequestContext stdcontext.Context
43+
44+
// ConversationID identifies the conversation whose context is being built.
45+
ConversationID string
46+
3947
// Bot Specific
4048
BotName string
4149
BotUsername string
@@ -47,6 +55,42 @@ type Context struct {
4755
Tools *ToolStore
4856
DisabledToolsInfo []ToolInfo // Info about tools that are unavailable in the current context (e.g., DM-only tools in a channel)
4957
Parameters map[string]interface{}
58+
59+
// MCPDynamicToolLoading indicates this context uses strict MCP dynamic loading.
60+
MCPDynamicToolLoading bool
61+
// MCPDynamicToolTelemetry receives low-cardinality dynamic MCP tool events.
62+
MCPDynamicToolTelemetry MCPDynamicToolTelemetry
63+
MCPDynamicToolSearchUsed bool
64+
MCPDynamicLoadedToolNames map[string]bool
65+
MCPDynamicSearchLoadCallSuccessRecorded map[string]bool
66+
67+
// DisabledMCPServerOrigins contains per-user disabled MCP server origins that
68+
// must be removed before strict registry construction.
69+
DisabledMCPServerOrigins []string
70+
71+
// KeepMCPTool, when non-nil, is applied to MCP tools before strict registry
72+
// construction and before flag-off visible MCP insertion.
73+
KeepMCPTool func(Tool) bool
74+
75+
// PreloadedMCPTools contains exact-or-bare MCP tool selectors for internal
76+
// predefined flows. They are selected only from the already-authorized MCP
77+
// catalog and are request scoped.
78+
PreloadedMCPTools []EnabledMCPTool
79+
80+
// MCPToolRegistry holds the strict MCP tool registry that was built
81+
// alongside Tools, when MCP dynamic tool loading is enabled. It is stashed
82+
// here so callers can replay loaded-tool restoration after the conversation
83+
// row exists without rebuilding the entire tool store.
84+
//
85+
// Stored as `any` to avoid an llm -> mcp import cycle: the mcp package
86+
// already imports llm, and the only consumer that needs the concrete type
87+
// is the llmcontext package, which can import both. Type-assert to
88+
// *mcp.ToolRegistry there.
89+
MCPToolRegistry any
90+
}
91+
92+
type MCPDynamicToolTelemetry interface {
93+
ObserveMCPDynamicToolEvent(botName, event, result string)
5094
}
5195

5296
// ContextOption defines a function that configures a Context
@@ -99,6 +143,53 @@ func (c *Context) CustomPromptVars() map[string]string {
99143
return vars
100144
}
101145

146+
func (c *Context) ObserveMCPDynamicToolEvent(event, result string) {
147+
if c == nil || c.MCPDynamicToolTelemetry == nil {
148+
return
149+
}
150+
151+
botName := c.BotUsername
152+
if botName == "" {
153+
botName = c.BotName
154+
}
155+
if botName == "" {
156+
botName = "unknown"
157+
}
158+
159+
c.MCPDynamicToolTelemetry.ObserveMCPDynamicToolEvent(botName, event, result)
160+
}
161+
162+
func (c *Context) MarkMCPDynamicToolSearch() {
163+
if c == nil {
164+
return
165+
}
166+
c.MCPDynamicToolSearchUsed = true
167+
}
168+
169+
func (c *Context) MarkMCPDynamicToolLoaded(name string) {
170+
if c == nil || name == "" {
171+
return
172+
}
173+
if c.MCPDynamicLoadedToolNames == nil {
174+
c.MCPDynamicLoadedToolNames = make(map[string]bool)
175+
}
176+
c.MCPDynamicLoadedToolNames[name] = true
177+
}
178+
179+
func (c *Context) ShouldRecordMCPDynamicSearchLoadCallSuccess(name string) bool {
180+
if c == nil || name == "" || !c.MCPDynamicToolSearchUsed || !c.MCPDynamicLoadedToolNames[name] {
181+
return false
182+
}
183+
if c.MCPDynamicSearchLoadCallSuccessRecorded == nil {
184+
c.MCPDynamicSearchLoadCallSuccessRecorded = make(map[string]bool)
185+
}
186+
if c.MCPDynamicSearchLoadCallSuccessRecorded[name] {
187+
return false
188+
}
189+
c.MCPDynamicSearchLoadCallSuccessRecorded[name] = true
190+
return true
191+
}
192+
102193
func (c Context) String() string {
103194
var result strings.Builder
104195
result.WriteString(fmt.Sprintf("Time: %v\nServerName: %v\nCompanyName: %v", c.Time, c.ServerName, c.CompanyName))

llm/context_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
)
1212

13+
type contextTelemetryEvent struct {
14+
botName string
15+
event string
16+
result string
17+
}
18+
19+
type fakeMCPDynamicTelemetry struct {
20+
events []contextTelemetryEvent
21+
}
22+
23+
func (t *fakeMCPDynamicTelemetry) ObserveMCPDynamicToolEvent(botName, event, result string) {
24+
t.events = append(t.events, contextTelemetryEvent{botName: botName, event: event, result: result})
25+
}
26+
1327
func TestContext_SetBotFields(t *testing.T) {
1428
c := NewContext()
1529
c.SetBotFields("BotDisplay", "botuser", "user-id-123", "gpt-4", "openai", "Be helpful and concise")
@@ -107,3 +121,51 @@ func TestContext_CustomPromptVars(t *testing.T) {
107121
})
108122
}
109123
}
124+
125+
func TestContextObserveMCPDynamicToolEventBotLabelFallbacks(t *testing.T) {
126+
tests := []struct {
127+
name string
128+
context *Context
129+
wantBotName string
130+
}{
131+
{
132+
name: "username",
133+
context: &Context{BotUsername: "matty", BotName: "Matty"},
134+
wantBotName: "matty",
135+
},
136+
{
137+
name: "display name",
138+
context: &Context{BotName: "Matty"},
139+
wantBotName: "Matty",
140+
},
141+
{
142+
name: "unknown",
143+
context: &Context{},
144+
wantBotName: "unknown",
145+
},
146+
}
147+
148+
for _, tt := range tests {
149+
t.Run(tt.name, func(t *testing.T) {
150+
telemetry := &fakeMCPDynamicTelemetry{}
151+
tt.context.MCPDynamicToolTelemetry = telemetry
152+
153+
tt.context.ObserveMCPDynamicToolEvent("search", "success")
154+
155+
assert.Equal(t, []contextTelemetryEvent{{botName: tt.wantBotName, event: "search", result: "success"}}, telemetry.events)
156+
})
157+
}
158+
}
159+
160+
func TestContextMCPDynamicSearchLoadCallSuccessState(t *testing.T) {
161+
c := &Context{}
162+
163+
assert.False(t, c.ShouldRecordMCPDynamicSearchLoadCallSuccess("jira__get_issue"))
164+
165+
c.MarkMCPDynamicToolSearch()
166+
assert.False(t, c.ShouldRecordMCPDynamicSearchLoadCallSuccess("jira__get_issue"))
167+
168+
c.MarkMCPDynamicToolLoaded("jira__get_issue")
169+
assert.True(t, c.ShouldRecordMCPDynamicSearchLoadCallSuccess("jira__get_issue"))
170+
assert.False(t, c.ShouldRecordMCPDynamicSearchLoadCallSuccess("jira__get_issue"))
171+
}

0 commit comments

Comments
 (0)