Add client-side SEP-1034 elicitation defaults conformance test (#19)

* feat: add client-side SEP-1034 conformance test for elicitation defaults

Adds a conformance test that validates client SDKs properly apply
default values for omitted fields in elicitation responses per SEP-1034.

The test spins up a server that sends elicitation requests with defaults,
and validates that the client returns content with those defaults applied.

* feat: use elicitInput() for SEP-1034 default validation

Updates client-side test to validate server-side default application
using elicitInput() helper, which applies defaults via validation layer.
This aligns with the TypeScript SDK's SEP-1034 implementation approach.

The test now validates:
- Server uses elicitInput() with schemas containing defaults
- Client returns empty/partial content
- Server automatically applies defaults during validation
- Defaults are correctly merged for all primitive types

* refactor: use server.request() instead of elicitInput() for client-side defaults test

Ensures conformance test validates true client-side default application
rather than server-side validation behavior.

* feat: add applyDefaults capability flag for opt-in default application

Enables conformance test to opt into client-side elicitation defaults
by setting the applyDefaults capability flag.
This commit is contained in:
Felix Weinberger 2025-11-11 14:05:11 +00:00 • committed by GitHub
commit f7bc687819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 640 additions and 1 deletions

View file

@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Test client for SEP-1034 client-side elicitation defaults
* This client intentionally returns empty/partial content in elicitation responses
* to verify that the SDK applies defaults for omitted fields.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
async function main(): Promise<void> {
const serverUrl = process.argv[2];
if (!serverUrl) {
console.error('Usage: elicitation-defaults-test <server-url>');
process.exit(1);
}
console.log(`Connecting to MCP server at: ${serverUrl}`);
try {
const client = new Client(
{
name: 'elicitation-defaults-test-client',
version: '1.0.0'
},
{
capabilities: {
elicitation: {
applyDefaults: true
}
}
}
);
// Register elicitation handler that returns empty content
// The SDK should fill in defaults for all omitted fields
client.setRequestHandler(ElicitRequestSchema, async (request) => {
console.log(
'📋 Received elicitation request:',
JSON.stringify(request.params, null, 2)
);
console.log(
'✅ Accepting with empty content - SDK should apply defaults'
);
// Return empty content - SDK should merge in defaults
return {
action: 'accept' as const,
content: {}
};
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
console.log('✅ Successfully connected to MCP server');
// List available tools
const tools = await client.listTools();
console.log(
'📦 Available tools:',
tools.tools.map((t) => t.name)
);
// Call the test tool which will trigger elicitation
const testTool = tools.tools.find(
(t) => t.name === 'test_client_elicitation_defaults'
);
if (!testTool) {
console.error('❌ Test tool not found: test_client_elicitation_defaults');
process.exit(1);
}
console.log('🔧 Calling test_client_elicitation_defaults tool...');
const result = await client.callTool({
name: 'test_client_elicitation_defaults',
arguments: {}
});
console.log('📄 Tool result:', JSON.stringify(result, null, 2));
await transport.close();
console.log('✅ Connection closed successfully');
process.exit(0);
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
}
}
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View file

@ -0,0 +1,536 @@
/**
* 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.js';
import express, { Request, Response } from 'express';
import { ScenarioUrls } from '../../types.js';
import { createRequestLogger } from '../request-logger.js';
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;
}
}

View file

@ -3,6 +3,7 @@ import { InitializeScenario } from './client/initialize.js';
import { ToolsCallScenario } from './client/tools_call.js';
import { AuthBasicDCRScenario } from './client/auth/basic-dcr.js';
import { AuthBasicMetadataVar1Scenario } from './client/auth/basic-metadata-var1.js';
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js';
// Import all new server test scenarios
import { ServerInitializeScenario } from './server/lifecycle.js';
@ -49,7 +50,11 @@ export const scenarios = new Map<string, Scenario>([
['initialize', new InitializeScenario()],
['tools-call', new ToolsCallScenario()],
['auth/basic-dcr', new AuthBasicDCRScenario()],
['auth/basic-metadata-var1', new AuthBasicMetadataVar1Scenario()]
['auth/basic-metadata-var1', new AuthBasicMetadataVar1Scenario()],
[
'elicitation-sep1034-client-defaults',
new ElicitationClientDefaultsScenario()
]
]);
export const clientScenarios = new Map<string, ClientScenario>([