diff --git a/.changeset/complete-context.md b/.changeset/complete-context.md new file mode 100644 index 0000000000..756989dd16 --- /dev/null +++ b/.changeset/complete-context.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Pass `ServerContext` as the third argument to prompt and resource template completion callbacks. + +This lets completion providers read request auth metadata from `ctx.http?.authInfo` and observe cancellation through `ctx.mcpReq.signal`, matching the context already available to tools, prompts, and resource callbacks. diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 82300f7df1..56975390b4 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,4 +1,4 @@ -import type { StandardSchemaV1 } from '@modelcontextprotocol/core'; +import type { ServerContext, StandardSchemaV1 } from '@modelcontextprotocol/core'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); @@ -6,7 +6,8 @@ export type CompleteCallback = ( value: StandardSchemaV1.InferInput, context?: { arguments?: Record; - } + }, + ctx?: ServerContext ) => StandardSchemaV1.InferInput[] | Promise[]>; export type CompletableMeta = { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index fb45fd5db6..4579cf95e1 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -351,16 +351,16 @@ export class McpServer { completions: {} }); - this.server.setRequestHandler('completion/complete', async (request): Promise => { + this.server.setRequestHandler('completion/complete', async (request, ctx): Promise => { switch (request.params.ref.type) { case 'ref/prompt': { assertCompleteRequestPrompt(request); - return this.handlePromptCompletion(request, request.params.ref); + return this.handlePromptCompletion(request, request.params.ref, ctx); } case 'ref/resource': { assertCompleteRequestResourceTemplate(request); - return this.handleResourceCompletion(request, request.params.ref); + return this.handleResourceCompletion(request, request.params.ref, ctx); } default: { @@ -372,7 +372,11 @@ export class McpServer { this._completionHandlerInitialized = true; } - private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { + private async handlePromptCompletion( + request: CompleteRequestPrompt, + ref: PromptReference, + ctx: ServerContext + ): Promise { const prompt = this._registeredPrompts[ref.name]; if (!prompt) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); @@ -397,13 +401,14 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value, request.params.context); + const suggestions = await completer(request.params.argument.value, request.params.context, ctx); return createCompletionResult(suggestions); } private async handleResourceCompletion( request: CompleteRequestResourceTemplate, - ref: ResourceTemplateReference + ref: ResourceTemplateReference, + ctx: ServerContext ): Promise { const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); @@ -421,7 +426,7 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value, request.params.context); + const suggestions = await completer(request.params.argument.value, request.params.context, ctx); return createCompletionResult(suggestions); } @@ -1059,7 +1064,8 @@ export type CompleteResourceTemplateCallback = ( value: string, context?: { arguments?: Record; - } + }, + ctx?: ServerContext ) => string[] | Promise; /** diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 92af09744c..dd75ae7ae5 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,9 +1,10 @@ import { Client } from '@modelcontextprotocol/client'; -import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core'; +import type { AuthInfo, CallToolResult, JSONRPCMessage, Notification, ServerContext, TextContent } from '@modelcontextprotocol/core'; import { getDisplayName, InMemoryTaskStore, InMemoryTransport, + isJSONRPCResultResponse, ProtocolErrorCode, UriTemplate, UrlElicitationRequiredError @@ -28,6 +29,13 @@ function createLatch() { }; } +function waitForMessage(transport: InMemoryTransport): Promise { + return new Promise((resolve, reject) => { + transport.onerror = reject; + transport.onmessage = message => resolve(message); + }); +} + describe('Zod v4', () => { describe('McpServer', () => { /*** @@ -3122,6 +3130,73 @@ describe('Zod v4', () => { expect(result.completion.total).toBe(2); }); + test('should pass request context to resource template completion callbacks', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const authInfo: AuthInfo = { + token: 'resource-token', + clientId: 'test-client', + scopes: ['resources:read'] + }; + let receivedContext: ServerContext | undefined; + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: (_value, _context, ctx) => { + receivedContext = ctx; + return ['books']; + } + } + }), + {}, + async () => ({ + contents: [{ uri: 'test://resource/test', text: 'Test content' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const responsePromise = waitForMessage(clientTransport); + + await mcpServer.server.connect(serverTransport); + await clientTransport.send( + { + jsonrpc: '2.0', + id: 1, + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: '' + } + } + }, + { authInfo } + ); + + const response = await responsePromise; + + expect(isJSONRPCResultResponse(response)).toBe(true); + expect(response.result).toEqual({ + completion: { + values: ['books'], + total: 1, + hasMore: false + } + }); + expect(receivedContext?.http?.authInfo).toEqual(authInfo); + expect(receivedContext?.mcpReq.signal).toBeInstanceOf(AbortSignal); + expect(receivedContext?.mcpReq.signal.aborted).toBe(false); + }); + /*** * Test: Pass Request ID to Resource Callback */ @@ -4030,6 +4105,79 @@ describe('Zod v4', () => { expect(result.completion.total).toBe(1); }); + test('should pass request context to prompt completion callbacks', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const authInfo: AuthInfo = { + token: 'prompt-token', + clientId: 'test-client', + scopes: ['prompts:read'] + }; + let receivedContext: ServerContext | undefined; + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: completable(z.string(), (_value, _context, ctx) => { + receivedContext = ctx; + return ['Alice']; + }) + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const responsePromise = waitForMessage(clientTransport); + + await mcpServer.server.connect(serverTransport); + await clientTransport.send( + { + jsonrpc: '2.0', + id: 1, + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: '' + } + } + }, + { authInfo } + ); + + const response = await responsePromise; + + expect(isJSONRPCResultResponse(response)).toBe(true); + expect(response.result).toEqual({ + completion: { + values: ['Alice'], + total: 1, + hasMore: false + } + }); + expect(receivedContext?.http?.authInfo).toEqual(authInfo); + expect(receivedContext?.mcpReq.signal).toBeInstanceOf(AbortSignal); + expect(receivedContext?.mcpReq.signal.aborted).toBe(false); + }); + /*** * Test: Pass Request ID to Prompt Callback */