Skip to content

Commit c42c4e2

Browse files
authored
Merge pull request #7 from gavinaboulhosn/feature/completions
feat: Add completion and resource template support
2 parents 3221a83 + d8bd6a9 commit c42c4e2

File tree

4 files changed

+278
-1
lines changed

4 files changed

+278
-1
lines changed

src/core/MCPServer.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ReadResourceRequestSchema,
1010
SubscribeRequestSchema,
1111
UnsubscribeRequestSchema,
12+
CompleteRequestSchema,
1213
} from '@modelcontextprotocol/sdk/types.js';
1314
import { ToolProtocol } from '../tools/BaseTool.js';
1415
import { PromptProtocol } from '../prompts/BasePrompt.js';
@@ -56,6 +57,7 @@ export type ServerCapabilities = {
5657
listChanged?: true;
5758
subscribe?: true;
5859
};
60+
completions?: {};
5961
};
6062

6163
export class MCPServer {
@@ -300,8 +302,11 @@ export class MCPServer {
300302

301303
targetServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
302304
logger.debug(`Received ListResourceTemplates request`);
305+
const templates = Array.from(this.resourcesMap.values())
306+
.map((resource) => resource.templateDefinition)
307+
.filter((t): t is NonNullable<typeof t> => Boolean(t));
303308
const response = {
304-
resourceTemplates: [],
309+
resourceTemplates: templates,
305310
nextCursor: undefined,
306311
};
307312
logger.debug(`Sending ListResourceTemplates response: ${JSON.stringify(response)}`);
@@ -336,6 +341,35 @@ export class MCPServer {
336341
return {};
337342
});
338343
}
344+
345+
if (this.capabilities.completions) {
346+
targetServer.setRequestHandler(CompleteRequestSchema, async (request: any) => {
347+
const { ref, argument } = request.params;
348+
349+
if (ref.type === 'ref/prompt') {
350+
const prompt = this.promptsMap.get(ref.name);
351+
if (prompt && typeof prompt.complete === 'function') {
352+
const result = await prompt.complete(argument.name, argument.value);
353+
return { completion: result };
354+
}
355+
return { completion: { values: [] } };
356+
}
357+
358+
if (ref.type === 'ref/resource') {
359+
for (const resource of this.resourcesMap.values()) {
360+
if (resource.templateDefinition?.uriTemplate === ref.uri || resource.uri === ref.uri) {
361+
if (typeof resource.complete === 'function') {
362+
const result = await resource.complete(argument.name, argument.value);
363+
return { completion: result };
364+
}
365+
}
366+
}
367+
return { completion: { values: [] } };
368+
}
369+
370+
return { completion: { values: [] } };
371+
});
372+
}
339373
}
340374

341375
private async detectCapabilities(): Promise<ServerCapabilities> {
@@ -354,6 +388,11 @@ export class MCPServer {
354388
logger.debug('Resources capability enabled');
355389
}
356390

391+
if (this.capabilities.prompts || this.capabilities.resources) {
392+
this.capabilities.completions = {};
393+
logger.debug('Completions capability enabled');
394+
}
395+
357396
logger.debug(`Capabilities detected: ${JSON.stringify(this.capabilities)}`);
358397
return this.capabilities;
359398
}

src/prompts/BasePrompt.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { z } from "zod";
22

3+
export type CompletionResult = {
4+
values: string[];
5+
total?: number;
6+
hasMore?: boolean;
7+
};
8+
39
export type PromptArgumentSchema<T> = {
410
[K in keyof T]: {
511
type: z.ZodType<T[K]>;
@@ -38,6 +44,11 @@ export interface PromptProtocol {
3844
};
3945
}>
4046
>;
47+
complete?(
48+
argumentName: string,
49+
value: string,
50+
context?: Record<string, string>
51+
): Promise<CompletionResult>;
4152
}
4253

4354
export abstract class MCPPrompt<TArgs extends Record<string, any> = {}>
@@ -85,6 +96,14 @@ export abstract class MCPPrompt<TArgs extends Record<string, any> = {}>
8596
return this.generateMessages(validatedArgs);
8697
}
8798

99+
async complete(
100+
argumentName: string,
101+
value: string,
102+
context?: Record<string, string>
103+
): Promise<CompletionResult> {
104+
return { values: [] };
105+
}
106+
88107
protected async fetch<T>(url: string, init?: RequestInit): Promise<T> {
89108
const response = await fetch(url, init);
90109
if (!response.ok) {

src/resources/BaseResource.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CompletionResult } from '../prompts/BasePrompt.js';
2+
13
export type ResourceContent = {
24
uri: string;
35
mimeType?: string;
@@ -25,9 +27,11 @@ export interface ResourceProtocol {
2527
description?: string;
2628
mimeType?: string;
2729
resourceDefinition: ResourceDefinition;
30+
templateDefinition?: ResourceTemplateDefinition;
2831
read(): Promise<ResourceContent[]>;
2932
subscribe?(): Promise<void>;
3033
unsubscribe?(): Promise<void>;
34+
complete?(argumentName: string, value: string): Promise<CompletionResult>;
3135
}
3236

3337
export abstract class MCPResource implements ResourceProtocol {
@@ -36,6 +40,11 @@ export abstract class MCPResource implements ResourceProtocol {
3640
description?: string;
3741
mimeType?: string;
3842

43+
protected template?: {
44+
uriTemplate: string;
45+
description?: string;
46+
};
47+
3948
get resourceDefinition(): ResourceDefinition {
4049
return {
4150
uri: this.uri,
@@ -45,8 +54,22 @@ export abstract class MCPResource implements ResourceProtocol {
4554
};
4655
}
4756

57+
get templateDefinition(): ResourceTemplateDefinition | undefined {
58+
if (!this.template) return undefined;
59+
return {
60+
uriTemplate: this.template.uriTemplate,
61+
name: this.name,
62+
description: this.template.description ?? this.description,
63+
mimeType: this.mimeType,
64+
};
65+
}
66+
4867
abstract read(): Promise<ResourceContent[]>;
4968

69+
async complete(argumentName: string, value: string): Promise<CompletionResult> {
70+
return { values: [] };
71+
}
72+
5073
async subscribe?(): Promise<void> {
5174
throw new Error("Subscription not implemented for this resource");
5275
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { describe, it, expect, beforeEach } from '@jest/globals';
2+
import { z } from 'zod';
3+
import { MCPPrompt } from '../../src/prompts/BasePrompt.js';
4+
import { MCPResource } from '../../src/resources/BaseResource.js';
5+
6+
describe('Completions', () => {
7+
describe('Prompt Completions', () => {
8+
class TestPrompt extends MCPPrompt<{ language: string }> {
9+
name = 'test_prompt';
10+
description = 'Test prompt with completion';
11+
schema = {
12+
language: {
13+
type: z.string(),
14+
description: 'Programming language',
15+
required: true as const,
16+
},
17+
};
18+
19+
async complete(argumentName: string, value: string) {
20+
if (argumentName === 'language') {
21+
const languages = ['python', 'typescript', 'rust', 'go'];
22+
const matches = languages.filter((l) => l.startsWith(value.toLowerCase()));
23+
return { values: matches, total: matches.length, hasMore: false };
24+
}
25+
return { values: [] };
26+
}
27+
28+
async generateMessages(args: { language: string }) {
29+
return [
30+
{
31+
role: 'user' as const,
32+
content: { type: 'text' as const, text: `Review ${args.language} code` },
33+
},
34+
];
35+
}
36+
}
37+
38+
let prompt: TestPrompt;
39+
beforeEach(() => {
40+
prompt = new TestPrompt();
41+
});
42+
43+
it('should return matching completions', async () => {
44+
const result = await prompt.complete('language', 'py');
45+
expect(result.values).toEqual(['python']);
46+
});
47+
48+
it('should return empty for no matches', async () => {
49+
const result = await prompt.complete('language', 'xyz');
50+
expect(result.values).toEqual([]);
51+
});
52+
53+
it('should return all values for empty input', async () => {
54+
const result = await prompt.complete('language', '');
55+
expect(result.values).toHaveLength(4);
56+
});
57+
58+
it('should return empty for unknown argument', async () => {
59+
const result = await prompt.complete('unknown', 'test');
60+
expect(result.values).toEqual([]);
61+
});
62+
63+
it('should include total and hasMore', async () => {
64+
const result = await prompt.complete('language', 't');
65+
expect(result).toHaveProperty('total');
66+
expect(result).toHaveProperty('hasMore');
67+
});
68+
});
69+
70+
describe('Default Prompt Completion', () => {
71+
class BasicPrompt extends MCPPrompt<{ name: string }> {
72+
name = 'basic';
73+
description = 'Basic prompt without custom completion';
74+
schema = {
75+
name: {
76+
type: z.string(),
77+
description: 'Name',
78+
required: true as const,
79+
},
80+
};
81+
async generateMessages(args: { name: string }) {
82+
return [
83+
{
84+
role: 'user' as const,
85+
content: { type: 'text' as const, text: args.name },
86+
},
87+
];
88+
}
89+
}
90+
91+
it('should return empty values by default', async () => {
92+
const prompt = new BasicPrompt();
93+
const result = await prompt.complete('name', 'test');
94+
expect(result.values).toEqual([]);
95+
});
96+
});
97+
98+
describe('Resource Templates', () => {
99+
class TemplateResource extends MCPResource {
100+
uri = 'config://app/theme';
101+
name = 'App Config';
102+
description = 'Application configuration';
103+
mimeType = 'application/json';
104+
105+
protected template = {
106+
uriTemplate: 'config://app/{section}',
107+
description: 'Access config by section',
108+
};
109+
110+
async complete(argumentName: string, value: string) {
111+
if (argumentName === 'section') {
112+
const sections = ['theme', 'network', 'auth'];
113+
return {
114+
values: sections.filter((s) => s.startsWith(value)),
115+
total: sections.length,
116+
};
117+
}
118+
return { values: [] };
119+
}
120+
121+
async read() {
122+
return [{ uri: this.uri, text: '{}', mimeType: this.mimeType }];
123+
}
124+
}
125+
126+
let resource: TemplateResource;
127+
beforeEach(() => {
128+
resource = new TemplateResource();
129+
});
130+
131+
it('should expose template definition', () => {
132+
const tmpl = resource.templateDefinition;
133+
expect(tmpl).toBeDefined();
134+
expect(tmpl!.uriTemplate).toBe('config://app/{section}');
135+
expect(tmpl!.name).toBe('App Config');
136+
expect(tmpl!.mimeType).toBe('application/json');
137+
});
138+
139+
it('should use template description over resource description', () => {
140+
const tmpl = resource.templateDefinition;
141+
expect(tmpl!.description).toBe('Access config by section');
142+
});
143+
144+
it('should fall back to resource description when template has none', () => {
145+
class FallbackResource extends MCPResource {
146+
uri = 'fallback://test';
147+
name = 'Fallback';
148+
description = 'Resource description';
149+
protected template = { uriTemplate: 'fallback://{id}' };
150+
async read() {
151+
return [{ uri: this.uri, text: 'data' }];
152+
}
153+
}
154+
const r = new FallbackResource();
155+
expect(r.templateDefinition!.description).toBe('Resource description');
156+
});
157+
158+
it('should return undefined templateDefinition when no template', () => {
159+
class PlainResource extends MCPResource {
160+
uri = 'plain://test';
161+
name = 'Plain';
162+
async read() {
163+
return [{ uri: this.uri, text: 'data' }];
164+
}
165+
}
166+
const plain = new PlainResource();
167+
expect(plain.templateDefinition).toBeUndefined();
168+
});
169+
170+
it('should provide completions for template arguments', async () => {
171+
const result = await resource.complete('section', 'th');
172+
expect(result.values).toEqual(['theme']);
173+
});
174+
175+
it('should return empty for unknown template argument', async () => {
176+
const result = await resource.complete('unknown', 'test');
177+
expect(result.values).toEqual([]);
178+
});
179+
});
180+
181+
describe('Default Resource Completion', () => {
182+
class BasicResource extends MCPResource {
183+
uri = 'basic://test';
184+
name = 'Basic';
185+
async read() {
186+
return [{ uri: this.uri, text: 'data' }];
187+
}
188+
}
189+
190+
it('should return empty values by default', async () => {
191+
const resource = new BasicResource();
192+
const result = await resource.complete('arg', 'val');
193+
expect(result.values).toEqual([]);
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)