Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/complete-context.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/server/src/server/completable.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { StandardSchemaV1 } from '@modelcontextprotocol/core';
import type { ServerContext, StandardSchemaV1 } from '@modelcontextprotocol/core';

export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');

export type CompleteCallback<T extends StandardSchemaV1 = StandardSchemaV1> = (
value: StandardSchemaV1.InferInput<T>,
context?: {
arguments?: Record<string, string>;
}
},
ctx?: ServerContext
) => StandardSchemaV1.InferInput<T>[] | Promise<StandardSchemaV1.InferInput<T>[]>;

export type CompletableMeta<T extends StandardSchemaV1 = StandardSchemaV1> = {
Expand Down
22 changes: 14 additions & 8 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,16 +351,16 @@ export class McpServer {
completions: {}
});

this.server.setRequestHandler('completion/complete', async (request): Promise<CompleteResult> => {
this.server.setRequestHandler('completion/complete', async (request, ctx): Promise<CompleteResult> => {
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: {
Expand All @@ -372,7 +372,11 @@ export class McpServer {
this._completionHandlerInitialized = true;
}

private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise<CompleteResult> {
private async handlePromptCompletion(
request: CompleteRequestPrompt,
ref: PromptReference,
ctx: ServerContext
): Promise<CompleteResult> {
const prompt = this._registeredPrompts[ref.name];
if (!prompt) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`);
Expand All @@ -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<CompleteResult> {
const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri);

Expand All @@ -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);
}

Expand Down Expand Up @@ -1059,7 +1064,8 @@ export type CompleteResourceTemplateCallback = (
value: string,
context?: {
arguments?: Record<string, string>;
}
},
ctx?: ServerContext
) => string[] | Promise<string[]>;

/**
Expand Down
150 changes: 149 additions & 1 deletion test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,6 +29,13 @@ function createLatch() {
};
}

function waitForMessage(transport: InMemoryTransport): Promise<JSONRPCMessage> {
return new Promise((resolve, reject) => {
transport.onerror = reject;
transport.onmessage = message => resolve(message);
});
}

describe('Zod v4', () => {
describe('McpServer', () => {
/***
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
Loading