Skip to content

Commit c3c85b3

Browse files
committed
Stop leaking OAuth as a concept to the LLM
Per @rumpl's review feedback on #2925: the LLM has no agency over the OAuth flow — it can't trigger it, grant it, or recover from it. Every OAuth-related string the catalog exposes to the model is a leaky abstraction, and the original bug (model stops and asks the user to repeat themselves after enabling an OAuth-protected server) is a direct consequence of leaking 'auth: OAuth — elicited on the next turn' in the enable result. This commit removes OAuth as a model-visible concept from every LLM-facing string in the catalog: - handleEnable: drop the per-auth-type switch. The success message is now a single line saying 'tools will appear on your next turn, proceed with the user's original request'. The only conditional emission is the WARNING for unresolved env vars, which is the one case the model has actual agency over (it can ask the user to set the variable and retry). - Instructions() workflow: step 2 drops the 'Authentication (OAuth or API key) is deferred' sentence; step 3 drops 'do not narrate the OAuth flow' (the model no longer learns OAuth exists, so the instruction can shrink to 'do not narrate connection setup'); step 5 reframes reset_remote_mcp_server_auth as 'if a previously enabled server starts rejecting requests, clear any persisted credentials' without mentioning OAuth tokens or authorization URLs. - Tool descriptions: enable_remote_mcp_server no longer mentions 'OAuth flow or API-key check'; reset_remote_mcp_server_auth no longer mentions 'OAuth credentials' or 'fresh authorization flow'. - ResetAuthArgs jsonschema and handleResetAuth result strings: drop every 'OAuth credentials' and 'fresh authorization flow' reference. Internal field names (oauthSuccessHandler, managedOAuth, etc.), Go package-doc comments, and slog debug messages still mention OAuth — those are developer-facing, not LLM-facing, and removing them would obscure intent for anyone reading the package. Tests updated: - TestEnableAddsServerAndFiresChangeNotification now asserts the result does NOT contain 'OAuth' or 'authorization', protecting against a regression where someone re-adds the leaky wording. - TestEnableAPIKeyEnvPresent no longer expects 'auth: API key' (the line is gone); still asserts no WARNING. - TestResetAuthClearsCredentials and TestResetAuthNoOpForNonOAuth updated for the new 'cleared credentials' / 'no persisted credentials' wording. The unresolved-header WARNING test still passes because the WARNING branch survived the rewrite under the unified 'len(missing) > 0' check.
1 parent dddf7fa commit c3c85b3

2 files changed

Lines changed: 43 additions & 49 deletions

File tree

pkg/tools/builtin/mcpcatalog/mcpcatalog.go

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -143,27 +143,22 @@ Workflow:
143143
Use any term related to the user's intent ("notion", "stripe",
144144
"docs", "search", "browser", …).
145145
2. Call ` + ToolNameEnable + ` with the server's "id" to activate it.
146-
This adds the server's tools to your set on the *next* turn.
147-
Authentication (OAuth or API key) is deferred — it is triggered when
148-
the host enumerates the server's tools, which happens once enabling
149-
completes. For api_key servers, make sure the listed env var(s) are
150-
set in the user's shell BEFORE enabling, otherwise the server will
151-
refuse the connection.
146+
The server's tools appear in your set on the *next* turn; the host
147+
handles any required connection setup. For api_key servers, make
148+
sure the listed env var(s) are set in the user's shell BEFORE
149+
enabling, otherwise the server will refuse the connection.
152150
3. On your NEXT turn (once the activated server's tools appear in
153151
your set), go straight to the user's ORIGINAL request using those
154152
tools. Do NOT stop and ask the user to repeat themselves; do NOT
155-
narrate the OAuth flow or say things like "OAuth will happen on
156-
the next turn" — if OAuth is needed the host surfaces the
157-
authorization dialog and the tool call resumes once the user
158-
grants access. Enabling a server is a means to an end, not a
159-
stopping point.
153+
narrate connection setup. Enabling a server is a means to an end,
154+
not a stopping point.
160155
4. Call ` + ToolNameDisable + ` to remove a server when no longer needed.
161156
This tool only appears once at least one server is enabled.
162-
5. If a previously authorized OAuth server starts rejecting requests
163-
(token revoked, scopes changed, signed in to the wrong account),
164-
call ` + ToolNameResetAuth + ` to wipe the persisted credentials.
165-
The next enable will trigger a fresh authorization URL. This tool
166-
also only appears once at least one server is enabled.
157+
5. If a previously enabled server starts rejecting requests
158+
(credentials revoked, scopes changed, signed in to the wrong
159+
account), call ` + ToolNameResetAuth + ` to clear any persisted
160+
credentials so the next enable starts fresh. This tool also only
161+
appears once at least one server is enabled.
167162
168163
Prefer enabling only the servers you actually need — every server adds
169164
tools to the prompt and contributes to context usage.`
@@ -315,7 +310,7 @@ func (t *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) {
315310
{
316311
Name: ToolNameEnable,
317312
Category: "mcp_catalog",
318-
Description: "Activate a remote MCP server from the catalog by id. Connection (and any required OAuth flow or API-key check) is deferred until the host next lists the agent's tools.",
313+
Description: "Activate a remote MCP server from the catalog by id. Connection setup is deferred until the host next enumerates tools; the server's tools become available on the next turn.",
319314
Parameters: tools.MustSchemaFor[EnableArgs](),
320315
OutputSchema: tools.MustSchemaFor[string](),
321316
Handler: tools.NewHandler(t.handleEnable),
@@ -352,7 +347,7 @@ func (t *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) {
352347
tools.Tool{
353348
Name: ToolNameResetAuth,
354349
Category: "mcp_catalog",
355-
Description: "Clear persisted OAuth credentials (access token, refresh token, dynamic-client-registration data) for a catalog server. The next enable will trigger a fresh authorization flow. No-op for api_key/none servers.",
350+
Description: "Clear any persisted credentials for a catalog server so the next enable starts a fresh connection. Use when a previously enabled server starts rejecting requests. No-op for servers with no persisted credentials.",
356351
Parameters: tools.MustSchemaFor[ResetAuthArgs](),
357352
OutputSchema: tools.MustSchemaFor[string](),
358353
Handler: tools.NewHandler(t.handleResetAuth),
@@ -600,28 +595,17 @@ func (t *Toolset) handleEnable(ctx context.Context, args EnableArgs) (*tools.Too
600595
}
601596

602597
msg := strings.Builder{}
603-
fmt.Fprintf(&msg, "enabled %q (%s) — connection deferred until the host next enumerates tools.\n", id, server.Title)
598+
fmt.Fprintf(&msg, "enabled %q (%s) — the server's tools will appear on your next turn. Proceed with the user's original request; the host handles any required connection setup.\n", id, server.Title)
604599
fmt.Fprintf(&msg, "endpoint: %s\n", server.URL)
605-
switch server.Auth.Type {
606-
case "oauth":
607-
msg.WriteString("auth: OAuth — the host will surface any required authorization (browser redirect or in-app dialog) the first time the server's tools are used, and resume the tool call once access is granted. On your next turn, continue with the user's original request; do not pause to announce the OAuth flow.\n")
608-
case "api_key":
609-
if len(missing) > 0 {
610-
fmt.Fprintf(&msg, "auth: API key — WARNING: the following env vars are NOT set: %s. Set them, then call %s and %s for this id again, otherwise tool calls will fail.\n",
611-
strings.Join(missing, ", "), ToolNameDisable, ToolNameEnable)
612-
} else {
613-
msg.WriteString("auth: API key — env vars present, ready to use.\n")
614-
}
615-
default:
616-
if len(missing) > 0 {
617-
// Headers reference env vars that didn't resolve, even though
618-
// the catalog says no auth is required — surface it so the
619-
// user is not surprised by a 401 on the first tool call.
620-
fmt.Fprintf(&msg, "auth: none — WARNING: header(s) reference unresolved env var(s): %s. Set them, then call %s and %s for this id again.\n",
621-
strings.Join(missing, ", "), ToolNameDisable, ToolNameEnable)
622-
} else {
623-
msg.WriteString("auth: none — ready to use.\n")
624-
}
600+
// Only surface unresolved env vars — the model can act on those by
601+
// asking the user to set the variable and retrying. Every other auth
602+
// detail (OAuth flow type, redirect URI, token persistence) is the
603+
// host's concern and is intentionally not exposed to the model; the
604+
// previous wording leaked "auth: OAuth — elicited on the next turn"
605+
// and led the model to stop and ask the user to repeat themselves.
606+
if len(missing) > 0 {
607+
fmt.Fprintf(&msg, "WARNING: the following env var(s) are NOT set: %s. Ask the user to set them, then call %s and %s for this id again, otherwise tool calls will fail.\n",
608+
strings.Join(missing, ", "), ToolNameDisable, ToolNameEnable)
625609
}
626610
return tools.ResultSuccess(msg.String()), nil
627611
}
@@ -768,7 +752,7 @@ func (t *Toolset) handleList(_ context.Context, _ ListArgs) (*tools.ToolCallResu
768752

769753
// ResetAuthArgs is the input schema for reset_remote_mcp_server_auth.
770754
type ResetAuthArgs struct {
771-
ID string `json:"id" jsonschema:"Catalog id of the server whose persisted OAuth credentials should be cleared."`
755+
ID string `json:"id" jsonschema:"Catalog id of the server whose persisted credentials should be cleared."`
772756
}
773757

774758
func (t *Toolset) handleResetAuth(ctx context.Context, args ResetAuthArgs) (*tools.ToolCallResult, error) {
@@ -779,7 +763,7 @@ func (t *Toolset) handleResetAuth(ctx context.Context, args ResetAuthArgs) (*too
779763
}
780764

781765
if server.Auth.Type != "oauth" {
782-
return tools.ResultSuccess(fmt.Sprintf("server %q uses %s auth — nothing to reset.", id, server.Auth.Type)), nil
766+
return tools.ResultSuccess(fmt.Sprintf("server %q has no persisted credentials — nothing to reset.", id)), nil
783767
}
784768

785769
// Stop and forget any live MCP toolset for this server. The active
@@ -809,15 +793,15 @@ func (t *Toolset) handleResetAuth(ctx context.Context, args ResetAuthArgs) (*too
809793
}
810794

811795
if err := t.removeOAuthToken(server.URL); err != nil {
812-
return tools.ResultError(fmt.Sprintf("failed to clear OAuth credentials for %q: %v", id, err)), nil
796+
return tools.ResultError(fmt.Sprintf("failed to clear credentials for %q: %v", id, err)), nil
813797
}
814798

815799
msg := strings.Builder{}
816-
fmt.Fprintf(&msg, "cleared OAuth credentials for %q (%s).\n", id, server.URL)
800+
fmt.Fprintf(&msg, "cleared credentials for %q (%s).\n", id, server.URL)
817801
if wasEnabled {
818-
msg.WriteString("the server was enabled and has been disabled; re-enable it to start a fresh authorization flow.\n")
802+
msg.WriteString("the server was enabled and has been disabled; re-enable it to start a fresh connection.\n")
819803
} else {
820-
msg.WriteString("enable the server to start a fresh authorization flow.\n")
804+
msg.WriteString("enable the server to start a fresh connection.\n")
821805
}
822806
return tools.ResultSuccess(msg.String()), nil
823807
}

pkg/tools/builtin/mcpcatalog/mcpcatalog_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,14 @@ func TestEnableDisableLifecycle(t *testing.T) {
122122
res, err := ts.handleEnable(ctx, EnableArgs{ID: oauthID})
123123
require.NoError(t, err)
124124
require.False(t, res.IsError, "enable failed: %s", res.Output)
125-
assert.Contains(t, res.Output, "OAuth")
125+
assert.Contains(t, res.Output, "enabled")
126+
// The OAuth-branch wording was intentionally removed: the model has
127+
// no agency over the OAuth flow, so the tool result no longer mentions
128+
// "OAuth" or "authorization" — the previous "elicited on the next
129+
// turn" wording caused the model to stop and ask the user to repeat
130+
// themselves. See handleEnable for the rationale.
131+
assert.NotContains(t, res.Output, "OAuth", "tool result must not leak OAuth details to the model")
132+
assert.NotContains(t, res.Output, "authorization", "tool result must not leak OAuth details to the model")
126133
assert.Equal(t, int32(1), changes.Load(), "enable should fire tools-changed handler exactly once")
127134

128135
ts.mu.RLock()
@@ -322,7 +329,10 @@ func TestEnableAPIKeyEnvPresent(t *testing.T) {
322329
res, err := ts.handleEnable(t.Context(), EnableArgs{ID: apiKeyID})
323330
require.NoError(t, err)
324331
require.False(t, res.IsError)
325-
assert.Contains(t, res.Output, "auth: API key")
332+
assert.Contains(t, res.Output, "enabled")
333+
// With every required env var present, no WARNING line is emitted —
334+
// the tool result is intentionally terse so the model proceeds to the
335+
// user's original request rather than narrating setup.
326336
assert.NotContains(t, res.Output, "WARNING")
327337
}
328338

@@ -550,7 +560,7 @@ func TestResetAuthForwardsToTokenStore(t *testing.T) {
550560
res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: oauthServer.ID})
551561
require.NoError(t, err)
552562
require.False(t, res.IsError, "reset auth: %s", res.Output)
553-
assert.Contains(t, res.Output, "cleared OAuth credentials")
563+
assert.Contains(t, res.Output, "cleared credentials")
554564
assert.Equal(t, []string{oauthServer.URL}, removedURLs,
555565
"removeOAuthToken must be called once with the catalog URL")
556566
}
@@ -588,7 +598,7 @@ func TestResetAuthNoOpForNonOAuth(t *testing.T) {
588598
res, err := ts.handleResetAuth(t.Context(), ResetAuthArgs{ID: apiKeyID})
589599
require.NoError(t, err)
590600
require.False(t, res.IsError)
591-
assert.Contains(t, res.Output, "nothing to reset")
601+
assert.Contains(t, res.Output, "no persisted credentials")
592602
assert.Zero(t, called, "api_key servers must not touch the OAuth token store")
593603
}
594604

0 commit comments

Comments
 (0)