Repository navigation
fix(sdk): surface the authenticate tool for a needs-auth MCP server #2295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
serafkul
wants to merge
2
commits into
Twigpine:main
Choose a base branch
from
serafkul:fix/sdk-mcp-auth-tool
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+219
−3
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| /** | ||
| * A server that wants OAuth must not be a dead end for an embedder. | ||
| * | ||
| * The CLI already surfaces the `authenticate` pseudo-tool for a `needs-auth` | ||
| * client, so the model can start the flow and hand the user a URL. The SDK | ||
| * path kept tools only for a connected client, which left a host with a | ||
| * status it could read (`system:init.mcp_servers`) and nothing it could do. | ||
| * | ||
| * The server here is a real local one rather than a stub of `connectToServer`: | ||
| * `needs-auth` is not decided by the 401 alone — the MCP SDK walks RFC 9728 | ||
| * resource metadata, RFC 8414 authorization-server metadata and dynamic client | ||
| * registration first, and only then reports that a redirect is needed. A stub | ||
| * would assert our guess about that chain instead of the behaviour. | ||
| */ | ||
|
|
||
| import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'bun:test' | ||
| import { mkdtemp, rm } from 'node:fs/promises' | ||
| import { tmpdir } from 'node:os' | ||
| import { join } from 'node:path' | ||
|
|
||
| import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' | ||
| import { | ||
| getClaudeConfigHomeDirOverrideForTesting, | ||
| setClaudeConfigHomeDirForTesting, | ||
| } from '../../utils/envUtils.js' | ||
|
|
||
| import { connectSdkMcpServers } from './permissions.js' | ||
|
|
||
| let server: ReturnType<typeof Bun.serve> | null = null | ||
| let configHome = '' | ||
| let originalConfigOverride: string | undefined | ||
| let hadMacro = false | ||
| let savedMacro: unknown | ||
|
|
||
| /** | ||
| * A server that asks to be signed into, and answers the discovery the MCP | ||
| * SDK does on the way there. It never issues a token: the point is to reach | ||
| * "a redirect is needed", which is what `needs-auth` means. | ||
| */ | ||
| function serveNeedingAuth(): string { | ||
| let base = '' | ||
| server = Bun.serve({ | ||
| port: 0, | ||
| fetch: request => { | ||
| const path = new URL(request.url).pathname | ||
| if (path.startsWith('/.well-known/oauth-protected-resource')) { | ||
| return Response.json({ resource: `${base}/mcp`, authorization_servers: [base] }) | ||
| } | ||
| if ( | ||
| path.startsWith('/.well-known/oauth-authorization-server') || | ||
| path.startsWith('/.well-known/openid-configuration') | ||
| ) { | ||
| return Response.json({ | ||
| issuer: base, | ||
| authorization_endpoint: `${base}/authorize`, | ||
| token_endpoint: `${base}/token`, | ||
| registration_endpoint: `${base}/register`, | ||
| response_types_supported: ['code'], | ||
| code_challenge_methods_supported: ['S256'], | ||
| }) | ||
| } | ||
| if (path === '/register') { | ||
| return Response.json( | ||
| { | ||
| client_id: 'stand-in-client', | ||
| client_secret: 'not-a-secret', | ||
| redirect_uris: ['http://127.0.0.1:1/callback'], | ||
| client_id_issued_at: Math.floor(Date.now() / 1000), | ||
| }, | ||
| { status: 201 }, | ||
| ) | ||
| } | ||
| return Response.json( | ||
| { error: 'invalid_token', error_description: 'sign in first' }, | ||
| { | ||
| status: 401, | ||
| headers: { | ||
| 'WWW-Authenticate': `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`, | ||
| }, | ||
| }, | ||
| ) | ||
| }, | ||
| }) | ||
| base = `http://127.0.0.1:${server.port}` | ||
| return `${base}/mcp` | ||
| } | ||
|
|
||
| /** A server that is simply broken, with nothing to sign into. */ | ||
| function serveBroken(): string { | ||
| server = Bun.serve({ port: 0, fetch: () => new Response('nope', { status: 500 }) }) | ||
| return `http://127.0.0.1:${server.port}/mcp` | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| await acquireSharedMutationLock('entrypoints/sdk/mcpNeedsAuth.test.ts') | ||
| // Build-time constants the connection path reads through. Absent, the | ||
| // failure is "MACRO is not defined" and says nothing about MCP. | ||
| hadMacro = Object.hasOwn(globalThis, 'MACRO') | ||
| savedMacro = (globalThis as Record<string, unknown>).MACRO | ||
| ;(globalThis as Record<string, unknown>).MACRO = { | ||
| VERSION: '0.0.0-test', | ||
| DISPLAY_VERSION: '0.0.0-test', | ||
| BUILD_TIME: 'test', | ||
| ISSUES_EXPLAINER: 'test', | ||
| PACKAGE_URL: 'test', | ||
| NATIVE_PACKAGE_URL: undefined, | ||
| } | ||
| }) | ||
|
|
||
| afterAll(() => { | ||
| if (hadMacro) (globalThis as Record<string, unknown>).MACRO = savedMacro | ||
| else delete (globalThis as Record<string, unknown>).MACRO | ||
| releaseSharedMutationLock() | ||
| }) | ||
|
|
||
| beforeEach(async () => { | ||
| originalConfigOverride = getClaudeConfigHomeDirOverrideForTesting() | ||
| // Both the 15-minute needs-auth cache and the client registration the SDK | ||
| // performs are keyed to the config home. Somewhere disposable, so this | ||
| // touches neither the developer's own store nor the next test. | ||
| configHome = await mkdtemp(join(tmpdir(), 'openclaude-mcp-auth-')) | ||
| setClaudeConfigHomeDirForTesting(configHome) | ||
| }) | ||
|
|
||
| afterEach(async () => { | ||
| server?.stop(true) | ||
| server = null | ||
| setClaudeConfigHomeDirForTesting(originalConfigOverride) | ||
| await rm(configHome, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| test('A SERVER THAT NEEDS SIGNING INTO ARRIVES WITH THE AUTHENTICATE TOOL', async () => { | ||
| const url = serveNeedingAuth() | ||
|
|
||
| const { clients, tools } = await connectSdkMcpServers({ gated: { type: 'http', url } }) | ||
|
|
||
| expect(clients.map(one => one.type)).toEqual(['needs-auth']) | ||
| expect(tools.map(one => one.name)).toEqual(['mcp__gated__authenticate']) | ||
| // The description is what the model reads to decide whether to call it, so | ||
| // it has to name the server and say what calling it does. | ||
| // Both arguments are ignored by this tool — the text is fixed when it is | ||
| // built — but the Tool interface asks for them. | ||
| const said = await tools[0]!.description({} as never, {} as never) | ||
| expect(said).toContain('gated') | ||
| expect(said).toContain('authentication') | ||
| // The embedder's session connects its MCP servers once and does not pick | ||
| // the real tools up after auth (v2.ts, self.mcpConnected), so the SDK path | ||
| // must not promise that it will. Raised in review on the PR. | ||
| expect(said).toContain('new session') | ||
| }, 30_000) | ||
|
serafkul marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * The branch is about authentication, not about failure in general. A server | ||
| * that is simply broken has nothing to sign into, and offering the model a | ||
| * sign-in for it would invite a wasted turn. | ||
| */ | ||
| test('a server that fails for another reason still gets no tool', async () => { | ||
| const url = serveBroken() | ||
|
|
||
| const { clients, tools } = await connectSdkMcpServers({ broken: { type: 'http', url } }) | ||
|
|
||
| expect(clients.map(one => one.type)).toEqual(['failed']) | ||
| expect(tools).toEqual([]) | ||
| }, 30_000) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Twigpine/openclaude
Length of output: 6926
Invoke the authentication tool and assert its authorization URL.
The test confirms that the tool is registered, but it does not exercise the user-visible result from calling
mcp__gated__authenticate. Call the returned tool against the local server and assert that its result contains the expectedauth_url. Clean up the pending OAuth flow after the assertion.🤖 Prompt for AI Agents