conformance/src/scenarios/server/tools.ts
dylan 0d824aebd8
Fix tools-name-format for spec Tool Names SHOULD rules on 2025-11-25+ (#380)
* 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>
2026-09-06 20:05:26 +00:00

1243 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
}