* fix(tools-list): align tools-name-format with 2025-11-25 spec prose Enforce dated spec Tool Names SHOULD rules (128 max, no slash), WARNING severity, 2025-11-25+ gate, negative fixture, and core-spec specReferences. Fixes #379 * test(tools-list): cast Connection.request mock for tsgo typecheck * test(tools-list): pin WARNING to offending tool name in negative test * test(tools-list): rename negative fixture to invalid-tool-names Document SEP-986 markdown vs integrated spec divergence in fixture header. * chore: trim explanatory comments to the essentials Keep the rule statement and the note that the dated spec (not the SEP-986 markdown) is authoritative; drop the process narration. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: triage <paulc@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
1243 lines
36 KiB
TypeScript
1243 lines
36 KiB
TypeScript
/**
|
||
* Tools test scenarios for MCP servers
|
||
*/
|
||
|
||
import {
|
||
ClientScenario,
|
||
ConformanceCheck,
|
||
DRAFT_PROTOCOL_VERSION,
|
||
specVersionAtLeast,
|
||
type SpecVersion
|
||
} from '../../types';
|
||
import type { RunContext } from '../../connection';
|
||
import type {
|
||
ListToolsResult,
|
||
CallToolResult
|
||
} from '../../spec-types/2025-06-18';
|
||
import {
|
||
connectToServer,
|
||
NotificationCollector
|
||
} from '../../connection/sdk-client';
|
||
import {
|
||
CreateMessageRequestSchema,
|
||
ElicitRequestSchema
|
||
} from '@modelcontextprotocol/sdk/types.js';
|
||
|
||
/**
|
||
* Tool name rules per the 2025-11-25 spec prose (#tool-names): 1–128 chars of
|
||
* [A-Za-z0-9_.-]. (The SEP-986 markdown still shows the older 64-char / `/`
|
||
* rules; the published spec is authoritative.)
|
||
*/
|
||
const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||
const TOOL_NAME_MAX_LENGTH = 128;
|
||
|
||
const TOOLS_NAME_FORMAT_SPEC_REFS = [
|
||
{
|
||
id: 'MCP-Tool-Names',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names'
|
||
},
|
||
{
|
||
id: 'MCP-Tool-Names-Draft',
|
||
url: 'https://modelcontextprotocol.io/specification/draft/server/tools#tool-names'
|
||
},
|
||
// Background only; the dated spec above is the rule source.
|
||
{
|
||
id: 'SEP-986-History',
|
||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986'
|
||
},
|
||
{
|
||
id: 'SEP-986-Spec-Integration',
|
||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1603'
|
||
}
|
||
];
|
||
|
||
/** The Tool Names rules first appear in the 2025-11-25 revision. */
|
||
export function toolNameFormatCheckApplies(specVersion: SpecVersion): boolean {
|
||
return specVersionAtLeast(specVersion, '2025-11-25');
|
||
}
|
||
|
||
export function validateToolNameFormat(name: string): string | null {
|
||
if (name.length < 1 || name.length > TOOL_NAME_MAX_LENGTH) {
|
||
return `length ${name.length} is outside 1-${TOOL_NAME_MAX_LENGTH}`;
|
||
}
|
||
if (!TOOL_NAME_PATTERN.test(name)) {
|
||
return 'contains characters outside [A-Za-z0-9_.-]';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function buildToolsNameFormatCheck(
|
||
tools: ReadonlyArray<{ name?: unknown }> | undefined
|
||
): ConformanceCheck {
|
||
const timestamp = new Date().toISOString();
|
||
const baseCheck = {
|
||
id: 'tools-name-format',
|
||
name: 'ToolsNameFormat',
|
||
description:
|
||
'Tool names SHOULD be 1-128 characters and match ^[A-Za-z0-9_.-]+$',
|
||
specReferences: TOOLS_NAME_FORMAT_SPEC_REFS,
|
||
timestamp
|
||
};
|
||
|
||
if (!Array.isArray(tools) || tools.length === 0) {
|
||
return {
|
||
...baseCheck,
|
||
status: 'INFO',
|
||
errorMessage: 'No tools advertised; nothing to validate',
|
||
details: { toolCount: 0 }
|
||
};
|
||
}
|
||
|
||
const toolResults: Record<string, string> = {};
|
||
const violations: string[] = [];
|
||
|
||
tools.forEach((tool, index) => {
|
||
const name = typeof tool.name === 'string' ? tool.name : '';
|
||
const key = name || `<tool[${index}] missing name>`;
|
||
const reason =
|
||
typeof tool.name === 'string'
|
||
? validateToolNameFormat(tool.name)
|
||
: 'name is not a string';
|
||
if (reason) {
|
||
toolResults[key] = `invalid: ${reason}`;
|
||
violations.push(`${key}: ${reason}`);
|
||
} else {
|
||
toolResults[key] = 'valid';
|
||
}
|
||
});
|
||
|
||
return {
|
||
...baseCheck,
|
||
status: violations.length === 0 ? 'SUCCESS' : 'WARNING',
|
||
errorMessage:
|
||
violations.length > 0
|
||
? `${violations.length} tool name(s) violate spec Tool Names SHOULD rules: ${violations.join('; ')}`
|
||
: undefined,
|
||
details: {
|
||
toolCount: tools.length,
|
||
results: toolResults
|
||
}
|
||
};
|
||
}
|
||
|
||
export class ToolsListScenario implements ClientScenario {
|
||
name = 'tools-list';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test listing available tools.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
**Endpoint**: \`tools/list\`
|
||
|
||
**Requirements**:
|
||
- Return array of all available tools
|
||
- Each tool MUST have:
|
||
- \`name\` (string)
|
||
- \`description\` (string)
|
||
- \`inputSchema\` (valid JSON Schema object)
|
||
- From 2025-11-25 onward, advertised \`name\` values SHOULD follow the spec Tool Names rules (1–128 chars, \`[A-Za-z0-9_.-]\` only) — see \`tools-name-format\` check`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<ListToolsResult>('tools/list');
|
||
|
||
// Validate response structure
|
||
const errors: string[] = [];
|
||
if (!result.tools) {
|
||
errors.push('Missing tools array');
|
||
} else {
|
||
if (!Array.isArray(result.tools)) {
|
||
errors.push('tools is not an array');
|
||
}
|
||
|
||
result.tools.forEach((tool, index) => {
|
||
if (!tool.name) errors.push(`Tool ${index}: missing name`);
|
||
if (!tool.description)
|
||
errors.push(`Tool ${index}: missing description`);
|
||
if (!tool.inputSchema)
|
||
errors.push(`Tool ${index}: missing inputSchema`);
|
||
});
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-list',
|
||
name: 'ToolsList',
|
||
description: 'Server lists available tools with valid structure',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-List',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools'
|
||
}
|
||
],
|
||
details: {
|
||
toolCount: result.tools?.length || 0,
|
||
tools: result.tools?.map((t) => t.name)
|
||
}
|
||
});
|
||
|
||
if (toolNameFormatCheckApplies(ctx.specVersion)) {
|
||
checks.push(buildToolsNameFormatCheck(result.tools));
|
||
}
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-list',
|
||
name: 'ToolsList',
|
||
description: 'Server lists available tools with valid structure',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-List',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallSimpleTextScenario implements ClientScenario {
|
||
name = 'tools-call-simple-text';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test calling a tool that returns simple text.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_simple_text\` with no arguments that returns:
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "This is a simple text response for testing."
|
||
}
|
||
]
|
||
}
|
||
\`\`\``;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_simple_text'
|
||
/* omit arguments as it is not required in the schema */
|
||
});
|
||
|
||
// Validate response
|
||
const errors: string[] = [];
|
||
const content = (result as any).content;
|
||
if (!content) errors.push('Missing content array');
|
||
if (!Array.isArray(content)) errors.push('content is not an array');
|
||
if (content && content.length === 0)
|
||
errors.push('content array is empty');
|
||
|
||
const textContent =
|
||
content && content.find((c: any) => c.type === 'text');
|
||
if (!textContent) errors.push('No text content found');
|
||
if (textContent && !textContent.text)
|
||
errors.push('Text content missing text field');
|
||
|
||
checks.push({
|
||
id: 'tools-call-simple-text',
|
||
name: 'ToolsCallSimpleText',
|
||
description: 'Tool returns simple text content',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
],
|
||
details: {
|
||
result
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-simple-text',
|
||
name: 'ToolsCallSimpleText',
|
||
description: 'Tool returns simple text content',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallImageScenario implements ClientScenario {
|
||
name = 'tools-call-image';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test calling a tool that returns image content.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_image_content\` with no arguments that returns:
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "image",
|
||
"data": "<base64-encoded-png>",
|
||
"mimeType": "image/png"
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
**Implementation Note**: Use a minimal test image (e.g., 1x1 red pixel PNG)`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_image_content',
|
||
arguments: {}
|
||
});
|
||
|
||
// Validate response
|
||
const errors: string[] = [];
|
||
const content = (result as any).content;
|
||
if (!content) errors.push('Missing content array');
|
||
|
||
const imageContent =
|
||
content && content.find((c: any) => c.type === 'image');
|
||
if (!imageContent) errors.push('No image content found');
|
||
if (imageContent && !imageContent.data)
|
||
errors.push('Image content missing data field');
|
||
if (imageContent && !imageContent.mimeType)
|
||
errors.push('Image content missing mimeType');
|
||
|
||
checks.push({
|
||
id: 'tools-call-image',
|
||
name: 'ToolsCallImage',
|
||
description: 'Tool returns image content',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
],
|
||
details: {
|
||
mimeType: imageContent?.mimeType,
|
||
hasData: !!imageContent?.data
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-image',
|
||
name: 'ToolsCallImage',
|
||
description: 'Tool returns image content',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallMultipleContentTypesScenario implements ClientScenario {
|
||
name = 'tools-call-mixed-content';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test tool returning multiple content types.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_multiple_content_types\` with no arguments that returns:
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "Multiple content types test:"
|
||
},
|
||
{
|
||
"type": "image",
|
||
"data": "<base64>",
|
||
"mimeType": "image/png"
|
||
},
|
||
{
|
||
"type": "resource",
|
||
"resource": {
|
||
"uri": "test://mixed-content-resource",
|
||
"mimeType": "application/json",
|
||
"text": "{"test":"data","value":123}"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
\`\`\``;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_multiple_content_types',
|
||
arguments: {}
|
||
});
|
||
|
||
// Validate response
|
||
const errors: string[] = [];
|
||
const content = (result as any).content;
|
||
if (!content) errors.push('Missing content array');
|
||
if (content && content.length < 2)
|
||
errors.push('Expected multiple content items');
|
||
|
||
const hasText = content && content.some((c: any) => c.type === 'text');
|
||
const hasImage = content && content.some((c: any) => c.type === 'image');
|
||
const hasResource =
|
||
content && content.some((c: any) => c.type === 'resource');
|
||
|
||
if (!hasText) errors.push('Missing text content');
|
||
if (!hasImage) errors.push('Missing image content');
|
||
if (!hasResource) errors.push('Missing resource content');
|
||
|
||
checks.push({
|
||
id: 'tools-call-mixed-content',
|
||
name: 'ToolsCallMixedContent',
|
||
description: 'Tool returns multiple content types',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
],
|
||
details: {
|
||
contentCount: content ? content.length : 0,
|
||
contentTypes: content ? content.map((c: any) => c.type) : []
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-mixed-content',
|
||
name: 'ToolsCallMixedContent',
|
||
description: 'Tool returns multiple content types',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallWithLoggingScenario implements ClientScenario {
|
||
name = 'tools-call-with-logging';
|
||
readonly source = {
|
||
introducedIn: '2025-06-18',
|
||
removedIn: DRAFT_PROTOCOL_VERSION
|
||
} as const;
|
||
description = `Test tool that sends log messages during execution.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_tool_with_logging\` with no arguments.
|
||
|
||
**Behavior**: During execution, send 3 log notifications at info level:
|
||
1. "Tool execution started"
|
||
2. "Tool processing data" (after ~50ms delay)
|
||
3. "Tool execution completed" (after another ~50ms delay)
|
||
|
||
**Returns**: Text content confirming execution
|
||
|
||
**Implementation Note**: The delays are important to test that clients can receive multiple log notifications during tool execution`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const { serverUrl } = ctx;
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const connection = await connectToServer(serverUrl, {}, ctx.specVersion);
|
||
const notifications = new NotificationCollector(connection.client);
|
||
|
||
// Set logging level to debug
|
||
await connection.client.setLoggingLevel('debug');
|
||
|
||
await connection.client.callTool({
|
||
name: 'test_tool_with_logging',
|
||
arguments: {}
|
||
});
|
||
|
||
// Wait a bit for notifications to arrive
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
|
||
// Check for log notifications from the NotificationCollector
|
||
const logNotifications = notifications.getNotifications();
|
||
|
||
const errors: string[] = [];
|
||
if (logNotifications.length === 0) {
|
||
errors.push('No log notifications received');
|
||
} else if (logNotifications.length < 3) {
|
||
errors.push(
|
||
`Expected at least 3 log messages, got ${logNotifications.length}`
|
||
);
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-call-with-logging',
|
||
name: 'ToolsCallWithLogging',
|
||
description: 'Tool sends log messages during execution',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Logging',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging'
|
||
}
|
||
],
|
||
details: {
|
||
logCount: logNotifications.length,
|
||
logs: logNotifications.map((n: any) => n.params)
|
||
}
|
||
});
|
||
|
||
await connection.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-with-logging',
|
||
name: 'ToolsCallWithLogging',
|
||
description: 'Tool sends log messages during execution',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Logging',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallErrorScenario implements ClientScenario {
|
||
name = 'tools-call-error';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test tool error reporting.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_error_handling\` with no arguments.
|
||
|
||
**Behavior**: Always throw an error
|
||
|
||
**Returns**: JSON-RPC response with \`isError: true\`
|
||
|
||
\`\`\`json
|
||
{
|
||
"isError": true,
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "This tool intentionally returns an error for testing"
|
||
}
|
||
]
|
||
}
|
||
\`\`\``;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result: any = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_error_handling',
|
||
arguments: {}
|
||
});
|
||
|
||
// Check if result has isError flag
|
||
const hasIsError = result.isError === true;
|
||
const hasContent = result.content && result.content.length > 0;
|
||
const hasErrorMessage = hasContent && result.content[0].text;
|
||
|
||
checks.push({
|
||
id: 'tools-call-error',
|
||
name: 'ToolsCallError',
|
||
description: 'Tool returns error correctly',
|
||
status: hasIsError && hasErrorMessage ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: !hasIsError
|
||
? 'Tool did not return isError: true'
|
||
: !hasErrorMessage
|
||
? 'Error result missing error message'
|
||
: undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Error-Handling',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle'
|
||
}
|
||
],
|
||
details: {
|
||
result
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-error',
|
||
name: 'ToolsCallError',
|
||
description: 'Tool returns error correctly',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Error-Handling',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallWithProgressScenario implements ClientScenario {
|
||
name = 'tools-call-with-progress';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test tool that reports progress notifications.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_tool_with_progress\` with no arguments.
|
||
|
||
**Behavior**: If \`_meta.progressToken\` is provided in request:
|
||
- Send progress notification: \`0/100\`
|
||
- Wait ~50ms
|
||
- Send progress notification: \`50/100\`
|
||
- Wait ~50ms
|
||
- Send progress notification: \`100/100\`
|
||
|
||
If no progress token provided, just execute with delays.
|
||
|
||
**Returns**: Text content confirming execution
|
||
|
||
**Progress Notification Format**:
|
||
|
||
\`\`\`json
|
||
{
|
||
"method": "notifications/progress",
|
||
"params": {
|
||
"progressToken": "<from request._meta.progressToken>",
|
||
"progress": 50,
|
||
"total": 100
|
||
}
|
||
}
|
||
\`\`\``;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_tool_with_progress',
|
||
arguments: {},
|
||
_meta: {
|
||
progressToken: 'progress-test-1'
|
||
}
|
||
});
|
||
|
||
const progressUpdates = conn.notifications
|
||
.filter((n) => n.method === 'notifications/progress')
|
||
.map(
|
||
(n) =>
|
||
n.params as {
|
||
progressToken?: string | number;
|
||
progress: number;
|
||
total?: number;
|
||
}
|
||
)
|
||
// The server must echo the request's progressToken back; ignore
|
||
// notifications for other tokens.
|
||
.filter((p) => p.progressToken === 'progress-test-1');
|
||
|
||
const errors: string[] = [];
|
||
if (progressUpdates.length === 0) {
|
||
errors.push('No progress notifications received');
|
||
} else if (progressUpdates.length < 3) {
|
||
errors.push(
|
||
`Expected at least 3 progress notifications, got ${progressUpdates.length}`
|
||
);
|
||
}
|
||
|
||
if (progressUpdates.length >= 3) {
|
||
const progress0 = progressUpdates[0].progress;
|
||
const progress1 = progressUpdates[1].progress;
|
||
const progress2 = progressUpdates[2].progress;
|
||
|
||
if (!(progress0 <= progress1 && progress1 <= progress2)) {
|
||
errors.push('Progress values should be increasing');
|
||
}
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-call-with-progress',
|
||
name: 'ToolsCallWithProgress',
|
||
description: 'Tool reports progress notifications',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Progress',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/progress'
|
||
}
|
||
],
|
||
details: {
|
||
progressCount: progressUpdates.length,
|
||
progressNotifications: progressUpdates,
|
||
result
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-with-progress',
|
||
name: 'ToolsCallWithProgress',
|
||
description: 'Tool reports progress notifications',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Progress',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/progress'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallSamplingScenario implements ClientScenario {
|
||
name = 'tools-call-sampling';
|
||
readonly source = {
|
||
introducedIn: '2025-06-18',
|
||
removedIn: DRAFT_PROTOCOL_VERSION
|
||
} as const;
|
||
description = `Test tool that requests LLM sampling from client.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_sampling\` with argument:
|
||
- \`prompt\` (string, required) - The prompt to send to the LLM
|
||
|
||
**Behavior**: Request LLM sampling from the client using \`sampling/createMessage\`
|
||
|
||
**Sampling Request**:
|
||
|
||
\`\`\`json
|
||
{
|
||
"method": "sampling/createMessage",
|
||
"params": {
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": {
|
||
"type": "text",
|
||
"text": "<prompt from arguments>"
|
||
}
|
||
}
|
||
],
|
||
"maxTokens": 100
|
||
}
|
||
}
|
||
\`\`\`
|
||
|
||
**Returns**: Text content with the LLM's response
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "LLM response: <response from sampling>"
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
**Implementation Note**: If the client doesn't support sampling (no \`sampling\` capability), return an error.`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const { serverUrl } = ctx;
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const connection = await connectToServer(serverUrl, {}, ctx.specVersion);
|
||
|
||
let samplingRequested = false;
|
||
connection.client.setRequestHandler(
|
||
CreateMessageRequestSchema,
|
||
async (_request) => {
|
||
samplingRequested = true;
|
||
return {
|
||
role: 'assistant',
|
||
content: {
|
||
type: 'text',
|
||
text: 'This is a test response from the client'
|
||
},
|
||
model: 'test-model',
|
||
stopReason: 'endTurn'
|
||
};
|
||
}
|
||
);
|
||
|
||
const result = await connection.client.callTool({
|
||
name: 'test_sampling',
|
||
arguments: {
|
||
prompt: 'Test prompt for sampling'
|
||
}
|
||
});
|
||
|
||
const errors: string[] = [];
|
||
if (!samplingRequested) {
|
||
errors.push('Server did not request sampling from client');
|
||
}
|
||
|
||
const content = (result as any).content;
|
||
if (!content || content.length === 0) {
|
||
errors.push('Tool did not return content');
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-call-sampling',
|
||
name: 'ToolsCallSampling',
|
||
description: 'Tool requests LLM sampling from client',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Sampling',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/sampling'
|
||
}
|
||
],
|
||
details: {
|
||
samplingRequested,
|
||
result
|
||
}
|
||
});
|
||
|
||
await connection.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-sampling',
|
||
name: 'ToolsCallSampling',
|
||
description: 'Tool requests LLM sampling from client',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Sampling',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/sampling'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallElicitationScenario implements ClientScenario {
|
||
name = 'tools-call-elicitation';
|
||
readonly source = {
|
||
introducedIn: '2025-06-18',
|
||
removedIn: DRAFT_PROTOCOL_VERSION
|
||
} as const;
|
||
description = `Test tool that requests user input (elicitation) from client.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_elicitation\` with argument:
|
||
- \`message\` (string, required) - The message to show the user
|
||
|
||
**Behavior**: Request user input from the client using \`elicitation/create\`
|
||
|
||
**Elicitation Request**:
|
||
|
||
\`\`\`json
|
||
{
|
||
"method": "elicitation/create",
|
||
"params": {
|
||
"message": "<message from arguments>",
|
||
"requestedSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"username": {
|
||
"type": "string",
|
||
"description": "User's response"
|
||
},
|
||
"email": {
|
||
"type": "string",
|
||
"description": "User's email address"
|
||
}
|
||
},
|
||
"required": ["username", "email"]
|
||
}
|
||
}
|
||
}
|
||
\`\`\`
|
||
|
||
**Returns**: Text content with the user's response
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "text",
|
||
"text": "User response: <action: accept/decline/cancel, content: {...}>"
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
**Implementation Note**: If the client doesn't support elicitation (no \`elicitation\` capability), return an error.`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const { serverUrl } = ctx;
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const connection = await connectToServer(serverUrl, {}, ctx.specVersion);
|
||
|
||
let elicitationRequested = false;
|
||
connection.client.setRequestHandler(
|
||
ElicitRequestSchema,
|
||
async (_request) => {
|
||
elicitationRequested = true;
|
||
return {
|
||
action: 'accept',
|
||
content: {
|
||
username: 'testuser',
|
||
email: 'test@example.com'
|
||
}
|
||
};
|
||
}
|
||
);
|
||
|
||
const result = await connection.client.callTool({
|
||
name: 'test_elicitation',
|
||
arguments: {
|
||
message: 'Please provide your information'
|
||
}
|
||
});
|
||
|
||
const errors: string[] = [];
|
||
if (!elicitationRequested) {
|
||
errors.push('Server did not request elicitation from client');
|
||
}
|
||
|
||
const content = (result as any).content;
|
||
if (!content || content.length === 0) {
|
||
errors.push('Tool did not return content');
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-call-elicitation',
|
||
name: 'ToolsCallElicitation',
|
||
description: 'Tool requests user input from client',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Elicitation',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/elicitation'
|
||
}
|
||
],
|
||
details: {
|
||
elicitationRequested,
|
||
result
|
||
}
|
||
});
|
||
|
||
await connection.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-elicitation',
|
||
name: 'ToolsCallElicitation',
|
||
description: 'Tool requests user input from client',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Elicitation',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/elicitation'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallAudioScenario implements ClientScenario {
|
||
name = 'tools-call-audio';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test calling a tool that returns audio content.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_audio_content\` with no arguments that returns:
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "audio",
|
||
"data": "<base64-encoded-wav>",
|
||
"mimeType": "audio/wav"
|
||
}
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
**Implementation Note**: Use a minimal test audio file`;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_audio_content',
|
||
arguments: {}
|
||
});
|
||
|
||
// Validate response
|
||
const errors: string[] = [];
|
||
const content = (result as any).content;
|
||
if (!content) errors.push('Missing content array');
|
||
if (!Array.isArray(content)) errors.push('content is not an array');
|
||
if (content && content.length === 0)
|
||
errors.push('content array is empty');
|
||
|
||
const audioContent =
|
||
content && content.find((c: any) => c.type === 'audio');
|
||
if (!audioContent) errors.push('No audio content found');
|
||
if (audioContent && !audioContent.data)
|
||
errors.push('Audio content missing data field');
|
||
if (audioContent && !audioContent.mimeType)
|
||
errors.push('Audio content missing mimeType field');
|
||
if (audioContent && audioContent.mimeType !== 'audio/wav')
|
||
errors.push(
|
||
`Expected mimeType 'audio/wav', got '${audioContent.mimeType}'`
|
||
);
|
||
|
||
checks.push({
|
||
id: 'tools-call-audio',
|
||
name: 'ToolsCallAudio',
|
||
description: 'Tool returns audio content',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
],
|
||
details: {
|
||
hasAudioContent: !!audioContent,
|
||
audioDataLength: audioContent?.data?.length || 0
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-audio',
|
||
name: 'ToolsCallAudio',
|
||
description: 'Tool returns audio content',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|
||
|
||
export class ToolsCallEmbeddedResourceScenario implements ClientScenario {
|
||
name = 'tools-call-embedded-resource';
|
||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||
description = `Test calling a tool that returns embedded resource content.
|
||
|
||
**Server Implementation Requirements:**
|
||
|
||
Implement tool \`test_embedded_resource\` with no arguments that returns:
|
||
|
||
\`\`\`json
|
||
{
|
||
"content": [
|
||
{
|
||
"type": "resource",
|
||
"resource": {
|
||
"uri": "test://embedded-resource",
|
||
"mimeType": "text/plain",
|
||
"text": "This is an embedded resource content."
|
||
}
|
||
}
|
||
]
|
||
}
|
||
\`\`\``;
|
||
|
||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||
const checks: ConformanceCheck[] = [];
|
||
|
||
try {
|
||
const conn = await ctx.connect();
|
||
|
||
const result = await conn.request<CallToolResult>('tools/call', {
|
||
name: 'test_embedded_resource',
|
||
arguments: {}
|
||
});
|
||
|
||
// Validate response
|
||
const errors: string[] = [];
|
||
const content = (result as any).content;
|
||
if (!content) errors.push('Missing content array');
|
||
if (!Array.isArray(content)) errors.push('content is not an array');
|
||
if (content && content.length === 0)
|
||
errors.push('content array is empty');
|
||
|
||
const resourceContent =
|
||
content && content.find((c: any) => c.type === 'resource');
|
||
if (!resourceContent) errors.push('No resource content found');
|
||
if (resourceContent && !resourceContent.resource)
|
||
errors.push('Resource content missing resource field');
|
||
if (resourceContent?.resource) {
|
||
if (!resourceContent.resource.uri)
|
||
errors.push('Resource missing uri field');
|
||
if (!resourceContent.resource.mimeType)
|
||
errors.push('Resource missing mimeType field');
|
||
if (!resourceContent.resource.text && !resourceContent.resource.blob) {
|
||
errors.push('Resource missing both text and blob fields');
|
||
}
|
||
}
|
||
|
||
checks.push({
|
||
id: 'tools-call-embedded-resource',
|
||
name: 'ToolsCallEmbeddedResource',
|
||
description: 'Tool returns embedded resource content',
|
||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
],
|
||
details: {
|
||
hasResourceContent: !!resourceContent,
|
||
resourceUri: resourceContent?.resource?.uri
|
||
}
|
||
});
|
||
|
||
await conn.close();
|
||
} catch (error) {
|
||
checks.push({
|
||
id: 'tools-call-embedded-resource',
|
||
name: 'ToolsCallEmbeddedResource',
|
||
description: 'Tool returns embedded resource content',
|
||
status: 'FAILURE',
|
||
timestamp: new Date().toISOString(),
|
||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
specReferences: [
|
||
{
|
||
id: 'MCP-Tools-Call',
|
||
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
return checks;
|
||
}
|
||
}
|