Skip to content
Merged
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
41 changes: 40 additions & 1 deletion src/core/MCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ReadResourceRequestSchema,
SubscribeRequestSchema,
UnsubscribeRequestSchema,
CompleteRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { ToolProtocol } from '../tools/BaseTool.js';
import { PromptProtocol } from '../prompts/BasePrompt.js';
Expand Down Expand Up @@ -56,6 +57,7 @@ export type ServerCapabilities = {
listChanged?: true;
subscribe?: true;
};
completions?: {};
};

export class MCPServer {
Expand Down Expand Up @@ -300,8 +302,11 @@ export class MCPServer {

targetServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
logger.debug(`Received ListResourceTemplates request`);
const templates = Array.from(this.resourcesMap.values())
.map((resource) => resource.templateDefinition)
.filter((t): t is NonNullable<typeof t> => Boolean(t));
const response = {
resourceTemplates: [],
resourceTemplates: templates,
nextCursor: undefined,
};
logger.debug(`Sending ListResourceTemplates response: ${JSON.stringify(response)}`);
Expand Down Expand Up @@ -336,6 +341,35 @@ export class MCPServer {
return {};
});
}

if (this.capabilities.completions) {
targetServer.setRequestHandler(CompleteRequestSchema, async (request: any) => {
const { ref, argument } = request.params;

if (ref.type === 'ref/prompt') {
const prompt = this.promptsMap.get(ref.name);
if (prompt && typeof prompt.complete === 'function') {
const result = await prompt.complete(argument.name, argument.value);
return { completion: result };
}
return { completion: { values: [] } };
}

if (ref.type === 'ref/resource') {
for (const resource of this.resourcesMap.values()) {
if (resource.templateDefinition?.uriTemplate === ref.uri || resource.uri === ref.uri) {
if (typeof resource.complete === 'function') {
const result = await resource.complete(argument.name, argument.value);
return { completion: result };
}
}
}
return { completion: { values: [] } };
}

return { completion: { values: [] } };
});
}
}

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

if (this.capabilities.prompts || this.capabilities.resources) {
this.capabilities.completions = {};
logger.debug('Completions capability enabled');
}

logger.debug(`Capabilities detected: ${JSON.stringify(this.capabilities)}`);
return this.capabilities;
}
Expand Down
19 changes: 19 additions & 0 deletions src/prompts/BasePrompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { z } from "zod";

export type CompletionResult = {
values: string[];
total?: number;
hasMore?: boolean;
};

export type PromptArgumentSchema<T> = {
[K in keyof T]: {
type: z.ZodType<T[K]>;
Expand Down Expand Up @@ -38,6 +44,11 @@ export interface PromptProtocol {
};
}>
>;
complete?(
argumentName: string,
value: string,
context?: Record<string, string>
): Promise<CompletionResult>;
}

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

async complete(
argumentName: string,
value: string,
context?: Record<string, string>
): Promise<CompletionResult> {
return { values: [] };
}

protected async fetch<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
Expand Down
23 changes: 23 additions & 0 deletions src/resources/BaseResource.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CompletionResult } from '../prompts/BasePrompt.js';

export type ResourceContent = {
uri: string;
mimeType?: string;
Expand Down Expand Up @@ -25,9 +27,11 @@ export interface ResourceProtocol {
description?: string;
mimeType?: string;
resourceDefinition: ResourceDefinition;
templateDefinition?: ResourceTemplateDefinition;
read(): Promise<ResourceContent[]>;
subscribe?(): Promise<void>;
unsubscribe?(): Promise<void>;
complete?(argumentName: string, value: string): Promise<CompletionResult>;
}

export abstract class MCPResource implements ResourceProtocol {
Expand All @@ -36,6 +40,11 @@ export abstract class MCPResource implements ResourceProtocol {
description?: string;
mimeType?: string;

protected template?: {
uriTemplate: string;
description?: string;
};

get resourceDefinition(): ResourceDefinition {
return {
uri: this.uri,
Expand All @@ -45,8 +54,22 @@ export abstract class MCPResource implements ResourceProtocol {
};
}

get templateDefinition(): ResourceTemplateDefinition | undefined {
if (!this.template) return undefined;
return {
uriTemplate: this.template.uriTemplate,
name: this.name,
description: this.template.description ?? this.description,
mimeType: this.mimeType,
};
}

abstract read(): Promise<ResourceContent[]>;

async complete(argumentName: string, value: string): Promise<CompletionResult> {
return { values: [] };
}

async subscribe?(): Promise<void> {
throw new Error("Subscription not implemented for this resource");
}
Expand Down
196 changes: 196 additions & 0 deletions tests/completions/completions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { z } from 'zod';
import { MCPPrompt } from '../../src/prompts/BasePrompt.js';
import { MCPResource } from '../../src/resources/BaseResource.js';

describe('Completions', () => {
describe('Prompt Completions', () => {
class TestPrompt extends MCPPrompt<{ language: string }> {
name = 'test_prompt';
description = 'Test prompt with completion';
schema = {
language: {
type: z.string(),
description: 'Programming language',
required: true as const,
},
};

async complete(argumentName: string, value: string) {
if (argumentName === 'language') {
const languages = ['python', 'typescript', 'rust', 'go'];
const matches = languages.filter((l) => l.startsWith(value.toLowerCase()));
return { values: matches, total: matches.length, hasMore: false };
}
return { values: [] };
}

async generateMessages(args: { language: string }) {
return [
{
role: 'user' as const,
content: { type: 'text' as const, text: `Review ${args.language} code` },
},
];
}
}

let prompt: TestPrompt;
beforeEach(() => {
prompt = new TestPrompt();
});

it('should return matching completions', async () => {
const result = await prompt.complete('language', 'py');
expect(result.values).toEqual(['python']);
});

it('should return empty for no matches', async () => {
const result = await prompt.complete('language', 'xyz');
expect(result.values).toEqual([]);
});

it('should return all values for empty input', async () => {
const result = await prompt.complete('language', '');
expect(result.values).toHaveLength(4);
});

it('should return empty for unknown argument', async () => {
const result = await prompt.complete('unknown', 'test');
expect(result.values).toEqual([]);
});

it('should include total and hasMore', async () => {
const result = await prompt.complete('language', 't');
expect(result).toHaveProperty('total');
expect(result).toHaveProperty('hasMore');
});
});

describe('Default Prompt Completion', () => {
class BasicPrompt extends MCPPrompt<{ name: string }> {
name = 'basic';
description = 'Basic prompt without custom completion';
schema = {
name: {
type: z.string(),
description: 'Name',
required: true as const,
},
};
async generateMessages(args: { name: string }) {
return [
{
role: 'user' as const,
content: { type: 'text' as const, text: args.name },
},
];
}
}

it('should return empty values by default', async () => {
const prompt = new BasicPrompt();
const result = await prompt.complete('name', 'test');
expect(result.values).toEqual([]);
});
});

describe('Resource Templates', () => {
class TemplateResource extends MCPResource {
uri = 'config://app/theme';
name = 'App Config';
description = 'Application configuration';
mimeType = 'application/json';

protected template = {
uriTemplate: 'config://app/{section}',
description: 'Access config by section',
};

async complete(argumentName: string, value: string) {
if (argumentName === 'section') {
const sections = ['theme', 'network', 'auth'];
return {
values: sections.filter((s) => s.startsWith(value)),
total: sections.length,
};
}
return { values: [] };
}

async read() {
return [{ uri: this.uri, text: '{}', mimeType: this.mimeType }];
}
}

let resource: TemplateResource;
beforeEach(() => {
resource = new TemplateResource();
});

it('should expose template definition', () => {
const tmpl = resource.templateDefinition;
expect(tmpl).toBeDefined();
expect(tmpl!.uriTemplate).toBe('config://app/{section}');
expect(tmpl!.name).toBe('App Config');
expect(tmpl!.mimeType).toBe('application/json');
});

it('should use template description over resource description', () => {
const tmpl = resource.templateDefinition;
expect(tmpl!.description).toBe('Access config by section');
});

it('should fall back to resource description when template has none', () => {
class FallbackResource extends MCPResource {
uri = 'fallback://test';
name = 'Fallback';
description = 'Resource description';
protected template = { uriTemplate: 'fallback://{id}' };
async read() {
return [{ uri: this.uri, text: 'data' }];
}
}
const r = new FallbackResource();
expect(r.templateDefinition!.description).toBe('Resource description');
});

it('should return undefined templateDefinition when no template', () => {
class PlainResource extends MCPResource {
uri = 'plain://test';
name = 'Plain';
async read() {
return [{ uri: this.uri, text: 'data' }];
}
}
const plain = new PlainResource();
expect(plain.templateDefinition).toBeUndefined();
});

it('should provide completions for template arguments', async () => {
const result = await resource.complete('section', 'th');
expect(result.values).toEqual(['theme']);
});

it('should return empty for unknown template argument', async () => {
const result = await resource.complete('unknown', 'test');
expect(result.values).toEqual([]);
});
});

describe('Default Resource Completion', () => {
class BasicResource extends MCPResource {
uri = 'basic://test';
name = 'Basic';
async read() {
return [{ uri: this.uri, text: 'data' }];
}
}

it('should return empty values by default', async () => {
const resource = new BasicResource();
const result = await resource.complete('arg', 'val');
expect(result.values).toEqual([]);
});
});
});