Merge pull request #634 from modelcontextprotocol/ihrpr/context-for-completable

Include context into completions
This commit is contained in:
bhosmer-ant 2025-06-16 11:11:58 -04:00 • committed by GitHub
commit e89137f21a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 490 additions and 23 deletions

View file

@ -11,6 +11,7 @@
- [Resources](#resources)
- [Tools](#tools)
- [Prompts](#prompts)
- [Completions](#completions)
- [Running Your Server](#running-your-server)
- [stdio](#stdio)
- [Streamable HTTP](#streamable-http)
@ -150,6 +151,33 @@ server.registerResource(
}]
})
);
// Resource with context-aware completion
server.registerResource(
"repository",
new ResourceTemplate("github://repos/{owner}/{repo}", {
list: undefined,
complete: {
// Provide intelligent completions based on previously resolved parameters
repo: (value, context) => {
if (context?.arguments?.["owner"] === "org1") {
return ["project1", "project2", "project3"].filter(r => r.startsWith(value));
}
return ["default-repo"].filter(r => r.startsWith(value));
}
}
}),
{
title: "GitHub Repository",
description: "Repository information"
},
async (uri, { owner, repo }) => ({
contents: [{
uri: uri.href,
text: `Repository: ${owner}/${repo}`
}]
})
);
```
### Tools
@ -233,12 +261,14 @@ Tools can return `ResourceLink` objects to reference resources without embedding
Prompts are reusable templates that help LLMs interact with your server effectively:
```typescript
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
server.registerPrompt(
"review-code",
{
title: "Code Review",
description: "Review code for best practices and potential issues",
arguments: { code: z.string() }
argsSchema: { code: z.string() }
},
({ code }) => ({
messages: [{
@ -250,6 +280,68 @@ server.registerPrompt(
}]
})
);
// Prompt with context-aware completion
server.registerPrompt(
"team-greeting",
{
title: "Team Greeting",
description: "Generate a greeting for team members",
argsSchema: {
department: completable(z.string(), (value) => {
// Department suggestions
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
}),
name: completable(z.string(), (value, context) => {
// Name suggestions based on selected department
const department = context?.arguments?.["department"];
if (department === "engineering") {
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
} else if (department === "sales") {
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
} else if (department === "marketing") {
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
}
return ["Guest"].filter(n => n.startsWith(value));
})
}
},
({ department, name }) => ({
messages: [{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}, welcome to the ${department} team!`
}
}]
})
);
```
### Completions
MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts).
#### Client Usage
```typescript
// Request completions for any argument
const result = await client.complete({
ref: {
type: "ref/prompt", // or "ref/resource"
name: "example" // or uri: "template://..."
},
argument: {
name: "argumentName",
value: "partial" // What the user has typed so far
},
context: { // Optional: Include previously resolved arguments
arguments: {
previousArg: "value"
}
}
});
```
### Display Names and Metadata
@ -805,6 +897,7 @@ const result = await client.callTool({
arg1: "value"
}
});
```
### Proxy Authorization Requests Upstream

View file

@ -15,6 +15,9 @@ export enum McpZodTypeKind {
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
value: T["_input"],
context?: {
arguments?: Record<string, string>;
},
) => T["_input"][] | Promise<T["_input"][]>;
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>

View file

@ -3521,12 +3521,12 @@ describe("prompt()", () => {
);
expect(result.resources).toHaveLength(2);
// Resource 1 should have its own metadata
expect(result.resources[0].name).toBe("Resource 1");
expect(result.resources[0].description).toBe("Individual resource description");
expect(result.resources[0].mimeType).toBe("text/plain");
// Resource 2 should inherit template metadata
expect(result.resources[1].name).toBe("Resource 2");
expect(result.resources[1].description).toBe("Template description");
@ -3592,7 +3592,7 @@ describe("prompt()", () => {
);
expect(result.resources).toHaveLength(1);
// All fields should be from the individual resource, not the template
expect(result.resources[0].name).toBe("Overridden Name");
expect(result.resources[0].description).toBe("Overridden description");
@ -3698,41 +3698,313 @@ describe("Tool title precedence", () => {
});
test("getDisplayName unit tests for title precedence", () => {
// Test 1: Only name
expect(getDisplayName({ name: "tool_name" })).toBe("tool_name");
// Test 2: Name and title - title wins
expect(getDisplayName({
name: "tool_name",
title: "Tool Title"
expect(getDisplayName({
name: "tool_name",
title: "Tool Title"
})).toBe("Tool Title");
// Test 3: Name and annotations.title - annotations.title wins
expect(getDisplayName({
expect(getDisplayName({
name: "tool_name",
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
// Test 4: All three - title wins (correct precedence)
expect(getDisplayName({
name: "tool_name",
expect(getDisplayName({
name: "tool_name",
title: "Regular Title",
annotations: { title: "Annotations Title" }
})).toBe("Regular Title");
// Test 5: Empty title should not be used
expect(getDisplayName({
name: "tool_name",
expect(getDisplayName({
name: "tool_name",
title: "",
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
// Test 6: Undefined vs null handling
expect(getDisplayName({
name: "tool_name",
expect(getDisplayName({
name: "tool_name",
title: undefined,
annotations: { title: "Annotations Title" }
})).toBe("Annotations Title");
});
test("should support resource template completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerResource(
"test",
new ResourceTemplate("github://repos/{owner}/{repo}", {
list: undefined,
complete: {
repo: (value, context) => {
if (context?.arguments?.["owner"] === "org1") {
return ["project1", "project2", "project3"].filter(r => r.startsWith(value));
} else if (context?.arguments?.["owner"] === "org2") {
return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value));
}
return [];
},
},
}),
{
title: "GitHub Repository",
description: "Repository information"
},
async () => ({
contents: [
{
uri: "github://repos/test/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with microsoft owner
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "p",
},
context: {
arguments: {
owner: "org1",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["project1", "project2", "project3"]);
expect(result1.completion.total).toBe(3);
// Test with facebook owner
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "r",
},
context: {
arguments: {
owner: "org2",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]);
expect(result2.completion.total).toBe(3);
// Test with no resolved context
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "t",
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual([]);
expect(result3.completion.total).toBe(0);
});
test("should support prompt argument completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerPrompt(
"test-prompt",
{
title: "Team Greeting",
description: "Generate a greeting for team members",
argsSchema: {
department: completable(z.string(), (value) => {
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
}),
name: completable(z.string(), (value, context) => {
const department = context?.arguments?.["department"];
if (department === "engineering") {
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
} else if (department === "sales") {
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
} else if (department === "marketing") {
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
}
return ["Guest"].filter(n => n.startsWith(value));
}),
}
},
async ({ department, name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}, welcome to the ${department} team!`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with engineering department
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "A",
},
context: {
arguments: {
department: "engineering",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["Alice"]);
// Test with sales department
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "D",
},
context: {
arguments: {
department: "sales",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["David"]);
// Test with marketing department
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
context: {
arguments: {
department: "marketing",
},
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual(["Grace"]);
// Test with no resolved context
const result4 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
},
},
CompleteResultSchema,
);
expect(result4.completion.values).toEqual(["Guest"]);
});
});

View file

@ -293,7 +293,7 @@ export class McpServer {
}
const def: CompletableDef<ZodString> = field._def;
const suggestions = await def.complete(request.params.argument.value);
const suggestions = await def.complete(request.params.argument.value, request.params.context);
return createCompletionResult(suggestions);
}
@ -324,7 +324,7 @@ export class McpServer {
return EMPTY_COMPLETION_RESULT;
}
const suggestions = await completer(request.params.argument.value);
const suggestions = await completer(request.params.argument.value, request.params.context);
return createCompletionResult(suggestions);
}
@ -1068,6 +1068,9 @@ export class McpServer {
*/
export type CompleteResourceTemplateCallback = (
value: string,
context?: {
arguments?: Record<string, string>;
},
) => string[] | Promise<string[]>;
/**

View file

@ -4,7 +4,8 @@ import {
ResourceLinkSchema,
ContentBlockSchema,
PromptMessageSchema,
CallToolResultSchema
CallToolResultSchema,
CompleteRequestSchema
} from "./types.js";
describe("Types", () => {
@ -223,4 +224,91 @@ describe("Types", () => {
}
});
});
describe("CompleteRequest", () => {
test("should validate a CompleteRequest without resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/prompt", name: "greeting" },
argument: { name: "name", value: "A" }
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.method).toBe("completion/complete");
expect(result.data.params.ref.type).toBe("ref/prompt");
expect(result.data.params.context).toBeUndefined();
}
});
test("should validate a CompleteRequest with resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" },
argument: { name: "repo", value: "t" },
context: {
arguments: {
"{owner}": "microsoft"
}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({
"{owner}": "microsoft"
});
}
});
test("should validate a CompleteRequest with empty resolved field", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/prompt", name: "test" },
argument: { name: "arg", value: "" },
context: {
arguments: {}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({});
}
});
test("should validate a CompleteRequest with multiple resolved variables", () => {
const request = {
method: "completion/complete",
params: {
ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" },
argument: { name: "id", value: "123" },
context: {
arguments: {
"{tenant}": "acme-corp",
"{resource}": "users"
}
}
}
};
const result = CompleteRequestSchema.safeParse(request);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.params.context?.arguments).toEqual({
"{tenant}": "acme-corp",
"{resource}": "users"
});
}
});
});
});

View file

@ -1169,6 +1169,14 @@ export const CompleteRequestSchema = RequestSchema.extend({
value: z.string(),
})
.passthrough(),
context: z.optional(
z.object({
/**
* Previously-resolved variables in a URI template or prompt.
*/
arguments: z.optional(z.record(z.string(), z.string())),
})
),
}),
});