* add cimd test and negative test * Improve auth-test-no-cimd.ts documentation for clarity Address review feedback: expanded comments to better explain what CIMD is, why this client is non-compliant, and what behavior it's testing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove .js extensions from relative imports tsx doesn't require .js extensions for TypeScript imports. Keeps .js for @modelcontextprotocol/sdk imports as those are external packages. Also improves auth-test-no-cimd.ts documentation for clarity. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Skip auth/basic-cimd test until SDK support lands Waiting on typescript-sdk PR #1127 for CIMD support. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix trailing whitespace in auth-test-no-cimd.ts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
/**
|
|
* SEP-1034: Elicitation defaults test
|
|
* Validates that clients properly apply default values for omitted fields
|
|
* in elicitation responses before sending them to the server
|
|
*/
|
|
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
ElicitResultSchema
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import type { Scenario, ConformanceCheck } from '../../types';
|
|
import express, { Request, Response } from 'express';
|
|
import { ScenarioUrls } from '../../types';
|
|
import { createRequestLogger } from '../request-logger';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
function isInitializeRequest(body: unknown): boolean {
|
|
if (
|
|
typeof body === 'object' &&
|
|
body !== null &&
|
|
'method' in body &&
|
|
(body as { method: string }).method === 'initialize'
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function createServer(checks: ConformanceCheck[]): {
|
|
app: express.Application;
|
|
cleanup: () => void;
|
|
} {
|
|
// Session management
|
|
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
|
const servers: { [sessionId: string]: Server } = {};
|
|
|
|
const createMcpServer = () => {
|
|
const server = new Server(
|
|
{
|
|
name: 'elicitation-defaults-test-server',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
return {
|
|
tools: [
|
|
{
|
|
name: 'test_client_elicitation_defaults',
|
|
description:
|
|
'Tests that client applies defaults for omitted elicitation fields',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: []
|
|
}
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
if (request.params.name === 'test_client_elicitation_defaults') {
|
|
try {
|
|
// Request elicitation with all optional fields having defaults
|
|
// Using raw server.request() to verify client applies defaults before sending response
|
|
const elicitResult = await server.request(
|
|
{
|
|
method: 'elicitation/create',
|
|
params: {
|
|
message:
|
|
'Test client default value handling - please accept with defaults',
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
name: {
|
|
type: 'string',
|
|
description: 'User name',
|
|
default: 'John Doe'
|
|
},
|
|
age: {
|
|
type: 'integer',
|
|
description: 'User age',
|
|
default: 30
|
|
},
|
|
score: {
|
|
type: 'number',
|
|
description: 'User score',
|
|
default: 95.5
|
|
},
|
|
status: {
|
|
type: 'string',
|
|
description: 'User status',
|
|
enum: ['active', 'inactive', 'pending'],
|
|
default: 'active'
|
|
},
|
|
verified: {
|
|
type: 'boolean',
|
|
description: 'Verification status',
|
|
default: true
|
|
}
|
|
},
|
|
required: [] // All fields optional, so defaults should apply
|
|
}
|
|
}
|
|
},
|
|
ElicitResultSchema
|
|
);
|
|
|
|
// Check if elicitation was accepted
|
|
if (elicitResult.action !== 'accept') {
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-general',
|
|
name: 'ClientElicitationSEP1034General',
|
|
description: 'Client accepts elicitation request',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Expected action 'accept', got '${elicitResult.action}'`,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
]
|
|
});
|
|
return {
|
|
content: [{ type: 'text', text: 'Elicitation was not accepted' }]
|
|
};
|
|
}
|
|
|
|
const content = elicitResult.content || {};
|
|
|
|
// Validate string default was applied
|
|
const stringErrors: string[] = [];
|
|
if (!('name' in content)) {
|
|
stringErrors.push(
|
|
'Field "name" missing - should have default "John Doe"'
|
|
);
|
|
} else if (typeof content.name !== 'string') {
|
|
stringErrors.push(
|
|
`Expected string for "name", got ${typeof content.name}`
|
|
);
|
|
}
|
|
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-string-default',
|
|
name: 'ClientElicitationSEP1034StringDefault',
|
|
description: 'Client applies string default value for elicitation',
|
|
status: stringErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
stringErrors.length > 0 ? stringErrors.join('; ') : undefined,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
],
|
|
details: {
|
|
field: 'name',
|
|
expectedDefault: 'John Doe',
|
|
receivedValue: content.name
|
|
}
|
|
});
|
|
|
|
// Validate integer default was applied
|
|
const integerErrors: string[] = [];
|
|
if (!('age' in content)) {
|
|
integerErrors.push('Field "age" missing - should have default 30');
|
|
} else if (typeof content.age !== 'number') {
|
|
integerErrors.push(
|
|
`Expected number for "age", got ${typeof content.age}`
|
|
);
|
|
}
|
|
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-integer-default',
|
|
name: 'ClientElicitationSEP1034IntegerDefault',
|
|
description: 'Client applies integer default value for elicitation',
|
|
status: integerErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
integerErrors.length > 0 ? integerErrors.join('; ') : undefined,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
],
|
|
details: {
|
|
field: 'age',
|
|
expectedDefault: 30,
|
|
receivedValue: content.age
|
|
}
|
|
});
|
|
|
|
// Validate number default was applied
|
|
const numberErrors: string[] = [];
|
|
if (!('score' in content)) {
|
|
numberErrors.push(
|
|
'Field "score" missing - should have default 95.5'
|
|
);
|
|
} else if (typeof content.score !== 'number') {
|
|
numberErrors.push(
|
|
`Expected number for "score", got ${typeof content.score}`
|
|
);
|
|
}
|
|
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-number-default',
|
|
name: 'ClientElicitationSEP1034NumberDefault',
|
|
description: 'Client applies number default value for elicitation',
|
|
status: numberErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
numberErrors.length > 0 ? numberErrors.join('; ') : undefined,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
],
|
|
details: {
|
|
field: 'score',
|
|
expectedDefault: 95.5,
|
|
receivedValue: content.score
|
|
}
|
|
});
|
|
|
|
// Validate enum default was applied
|
|
const enumErrors: string[] = [];
|
|
if (!('status' in content)) {
|
|
enumErrors.push(
|
|
'Field "status" missing - should have default "active"'
|
|
);
|
|
} else if (typeof content.status !== 'string') {
|
|
enumErrors.push(
|
|
`Expected string for "status", got ${typeof content.status}`
|
|
);
|
|
} else if (
|
|
!['active', 'inactive', 'pending'].includes(content.status)
|
|
) {
|
|
enumErrors.push(
|
|
`Value "${content.status}" is not a valid enum member`
|
|
);
|
|
}
|
|
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-enum-default',
|
|
name: 'ClientElicitationSEP1034EnumDefault',
|
|
description: 'Client applies enum default value for elicitation',
|
|
status: enumErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
enumErrors.length > 0 ? enumErrors.join('; ') : undefined,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
],
|
|
details: {
|
|
field: 'status',
|
|
expectedDefault: 'active',
|
|
receivedValue: content.status
|
|
}
|
|
});
|
|
|
|
// Validate boolean default was applied
|
|
const booleanErrors: string[] = [];
|
|
if (!('verified' in content)) {
|
|
booleanErrors.push(
|
|
'Field "verified" missing - should have default true'
|
|
);
|
|
} else if (typeof content.verified !== 'boolean') {
|
|
booleanErrors.push(
|
|
`Expected boolean for "verified", got ${typeof content.verified}`
|
|
);
|
|
}
|
|
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-boolean-default',
|
|
name: 'ClientElicitationSEP1034BooleanDefault',
|
|
description: 'Client applies boolean default value for elicitation',
|
|
status: booleanErrors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
booleanErrors.length > 0 ? booleanErrors.join('; ') : undefined,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
],
|
|
details: {
|
|
field: 'verified',
|
|
expectedDefault: true,
|
|
receivedValue: content.verified
|
|
}
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Elicitation completed: ${JSON.stringify(content)}`
|
|
}
|
|
]
|
|
};
|
|
} catch (error: any) {
|
|
checks.push({
|
|
id: 'client-elicitation-sep1034-general',
|
|
name: 'ClientElicitationSEP1034General',
|
|
description: 'Client handles elicitation with defaults',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Elicitation failed: ${error.message}`,
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
]
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Elicitation error: ${error.message}`
|
|
}
|
|
]
|
|
};
|
|
}
|
|
}
|
|
|
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
});
|
|
|
|
return server;
|
|
};
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
app.use(
|
|
createRequestLogger(checks, {
|
|
incomingId: 'incoming-request',
|
|
outgoingId: 'outgoing-response',
|
|
mcpRoute: '/mcp'
|
|
})
|
|
);
|
|
|
|
// Handle POST requests - stateful mode
|
|
app.post('/mcp', async (req: Request, res: Response) => {
|
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
|
|
try {
|
|
let transport: StreamableHTTPServerTransport;
|
|
|
|
if (sessionId && transports[sessionId]) {
|
|
// Reuse existing transport for established sessions
|
|
transport = transports[sessionId];
|
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
// Create new transport for initialization requests
|
|
const mcpServer = createMcpServer();
|
|
|
|
transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
onsessioninitialized: (newSessionId) => {
|
|
transports[newSessionId] = transport;
|
|
servers[newSessionId] = mcpServer;
|
|
}
|
|
});
|
|
|
|
transport.onclose = () => {
|
|
const sid = transport.sessionId;
|
|
if (sid && transports[sid]) {
|
|
delete transports[sid];
|
|
if (servers[sid]) {
|
|
servers[sid].close();
|
|
delete servers[sid];
|
|
}
|
|
}
|
|
};
|
|
|
|
await mcpServer.connect(transport);
|
|
await transport.handleRequest(req, res, req.body);
|
|
return;
|
|
} else {
|
|
res.status(400).json({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32000,
|
|
message: 'Invalid or missing session ID'
|
|
},
|
|
id: null
|
|
});
|
|
return;
|
|
}
|
|
|
|
await transport.handleRequest(req, res, req.body);
|
|
} catch {
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32603,
|
|
message: 'Internal server error'
|
|
},
|
|
id: null
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle GET requests - SSE streams for sessions
|
|
app.get('/mcp', async (req: Request, res: Response) => {
|
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
|
|
if (!sessionId || !transports[sessionId]) {
|
|
res.status(400).send('Invalid or missing session ID');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const transport = transports[sessionId];
|
|
await transport.handleRequest(req, res);
|
|
} catch {
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Error establishing SSE stream');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle DELETE requests - session termination
|
|
app.delete('/mcp', async (req: Request, res: Response) => {
|
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
|
|
if (!sessionId || !transports[sessionId]) {
|
|
res.status(400).send('Invalid or missing session ID');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const transport = transports[sessionId];
|
|
await transport.handleRequest(req, res);
|
|
} catch {
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Error handling termination');
|
|
}
|
|
}
|
|
});
|
|
|
|
const cleanup = () => {
|
|
// Clean up all sessions
|
|
for (const sessionId of Object.keys(transports)) {
|
|
if (servers[sessionId]) {
|
|
servers[sessionId].close();
|
|
}
|
|
}
|
|
};
|
|
|
|
return { app, cleanup };
|
|
}
|
|
|
|
export class ElicitationClientDefaultsScenario implements Scenario {
|
|
name = 'elicitation-sep1034-client-defaults';
|
|
description =
|
|
'Tests client applies default values for omitted elicitation fields (SEP-1034)';
|
|
private app: express.Application | null = null;
|
|
private httpServer: any = null;
|
|
private checks: ConformanceCheck[] = [];
|
|
private cleanup: (() => void) | null = null;
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
const { app, cleanup } = createServer(this.checks);
|
|
this.app = app;
|
|
this.cleanup = cleanup;
|
|
this.httpServer = this.app.listen(0);
|
|
const port = this.httpServer.address().port;
|
|
return { serverUrl: `http://localhost:${port}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
if (this.cleanup) {
|
|
this.cleanup();
|
|
this.cleanup = null;
|
|
}
|
|
if (this.httpServer) {
|
|
await new Promise((resolve) => this.httpServer.close(resolve));
|
|
this.httpServer = null;
|
|
}
|
|
this.app = null;
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
const expectedSlugs = [
|
|
'client-elicitation-sep1034-string-default',
|
|
'client-elicitation-sep1034-integer-default',
|
|
'client-elicitation-sep1034-number-default',
|
|
'client-elicitation-sep1034-enum-default',
|
|
'client-elicitation-sep1034-boolean-default'
|
|
];
|
|
|
|
// Add failures for any checks that weren't triggered (tool not called)
|
|
for (const slug of expectedSlugs) {
|
|
if (!this.checks.find((c) => c.id === slug)) {
|
|
this.checks.push({
|
|
id: slug,
|
|
name: slug.replace(/-/g, ''),
|
|
description: `Server applies ${slug.split('-')[4]} default for elicitation`,
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
details: { message: 'Tool was not called by client' },
|
|
specReferences: [
|
|
{
|
|
id: 'SEP-1034',
|
|
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034'
|
|
}
|
|
]
|
|
});
|
|
}
|
|
}
|
|
return this.checks;
|
|
}
|
|
}
|