fix(examples): create fresh McpServer per client in three examples (#1610)

This commit is contained in:
Niels Kaspers 2026-03-02 12:49:56 +02:00 • committed by GitHub
commit 52186d1884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 378 additions and 336 deletions

View file

@ -14,305 +14,309 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults
// The validator supports format validation (email, date, etc.) if ajv-formats is installed
const mcpServer = new McpServer(
{
name: 'form-elicitation-example-server',
version: '1.0.0'
},
{
capabilities: {}
}
);
/**
* Example 1: Simple user registration tool
* Collects username, email, and password from the user
*/
mcpServer.registerTool(
'register_user',
{
description: 'Register a new user account by collecting their information'
},
async () => {
try {
// Request user information through form elicitation
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your registration information:',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your desired username (3-20 characters)',
minLength: 3,
maxLength: 20
},
email: {
type: 'string',
title: 'Email',
description: 'Your email address',
format: 'email'
},
password: {
type: 'string',
title: 'Password',
description: 'Your password (min 8 characters)',
minLength: 8
},
newsletter: {
type: 'boolean',
title: 'Newsletter',
description: 'Subscribe to newsletter?',
default: false
}
},
required: ['username', 'email', 'password']
}
});
// Handle the different possible actions
if (result.action === 'accept' && result.content) {
const { username, email, newsletter } = result.content as {
username: string;
email: string;
password: string;
newsletter?: boolean;
};
return {
content: [
{
type: 'text',
text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [
{
type: 'text',
text: 'Registration cancelled by user.'
}
]
};
} else {
return {
content: [
{
type: 'text',
text: 'Registration was cancelled.'
}
]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
// Create a fresh MCP server per client connection to avoid shared state between clients.
// The validator supports format validation (email, date, etc.) if ajv-formats is installed.
const getServer = () => {
const mcpServer = new McpServer(
{
name: 'form-elicitation-example-server',
version: '1.0.0'
},
{
capabilities: {}
}
}
);
);
/**
* Example 2: Multi-step workflow with multiple form elicitation requests
* Demonstrates how to collect information in multiple steps
*/
mcpServer.registerTool(
'create_event',
{
description: 'Create a calendar event by collecting event details'
},
async () => {
try {
// Step 1: Collect basic event information
const basicInfo = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Step 1: Enter basic event information',
requestedSchema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Event Title',
description: 'Name of the event',
minLength: 1
/**
* Example 1: Simple user registration tool
* Collects username, email, and password from the user
*/
mcpServer.registerTool(
'register_user',
{
description: 'Register a new user account by collecting their information'
},
async () => {
try {
// Request user information through form elicitation
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your registration information:',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your desired username (3-20 characters)',
minLength: 3,
maxLength: 20
},
email: {
type: 'string',
title: 'Email',
description: 'Your email address',
format: 'email'
},
password: {
type: 'string',
title: 'Password',
description: 'Your password (min 8 characters)',
minLength: 8
},
newsletter: {
type: 'boolean',
title: 'Newsletter',
description: 'Subscribe to newsletter?',
default: false
}
},
description: {
type: 'string',
title: 'Description',
description: 'Event description (optional)'
}
},
required: ['title']
}
});
if (basicInfo.action !== 'accept' || !basicInfo.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Step 2: Collect date and time
const dateTime = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Step 2: Enter date and time',
requestedSchema: {
type: 'object',
properties: {
date: {
type: 'string',
title: 'Date',
description: 'Event date',
format: 'date'
},
startTime: {
type: 'string',
title: 'Start Time',
description: 'Event start time (HH:MM)'
},
duration: {
type: 'integer',
title: 'Duration',
description: 'Duration in minutes',
minimum: 15,
maximum: 480
}
},
required: ['date', 'startTime', 'duration']
}
});
if (dateTime.action !== 'accept' || !dateTime.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Combine all collected information
const event = {
...basicInfo.content,
...dateTime.content
};
return {
content: [
{
type: 'text',
text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
required: ['username', 'email', 'password']
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
});
/**
* Example 3: Collecting address information
* Demonstrates validation with patterns and optional fields
*/
mcpServer.registerTool(
'update_shipping_address',
{
description: 'Update shipping address with validation'
},
async () => {
try {
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your shipping address:',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Recipient name',
minLength: 1
},
street: {
type: 'string',
title: 'Street Address',
minLength: 1
},
city: {
type: 'string',
title: 'City',
minLength: 1
},
state: {
type: 'string',
title: 'State/Province',
minLength: 2,
maxLength: 2
},
zipCode: {
type: 'string',
title: 'ZIP/Postal Code',
description: '5-digit ZIP code'
},
phone: {
type: 'string',
title: 'Phone Number (optional)',
description: 'Contact phone number'
}
},
required: ['name', 'street', 'city', 'state', 'zipCode']
// Handle the different possible actions
if (result.action === 'accept' && result.content) {
const { username, email, newsletter } = result.content as {
username: string;
email: string;
password: string;
newsletter?: boolean;
};
return {
content: [
{
type: 'text',
text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [
{
type: 'text',
text: 'Registration cancelled by user.'
}
]
};
} else {
return {
content: [
{
type: 'text',
text: 'Registration was cancelled.'
}
]
};
}
});
if (result.action === 'accept' && result.content) {
} catch (error) {
return {
content: [
{
type: 'text',
text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
/**
* Example 2: Multi-step workflow with multiple form elicitation requests
* Demonstrates how to collect information in multiple steps
*/
mcpServer.registerTool(
'create_event',
{
description: 'Create a calendar event by collecting event details'
},
async () => {
try {
// Step 1: Collect basic event information
const basicInfo = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Step 1: Enter basic event information',
requestedSchema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Event Title',
description: 'Name of the event',
minLength: 1
},
description: {
type: 'string',
title: 'Description',
description: 'Event description (optional)'
}
},
required: ['title']
}
});
if (basicInfo.action !== 'accept' || !basicInfo.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Step 2: Collect date and time
const dateTime = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Step 2: Enter date and time',
requestedSchema: {
type: 'object',
properties: {
date: {
type: 'string',
title: 'Date',
description: 'Event date',
format: 'date'
},
startTime: {
type: 'string',
title: 'Start Time',
description: 'Event start time (HH:MM)'
},
duration: {
type: 'integer',
title: 'Duration',
description: 'Duration in minutes',
minimum: 15,
maximum: 480
}
},
required: ['date', 'startTime', 'duration']
}
});
if (dateTime.action !== 'accept' || !dateTime.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Combine all collected information
const event = {
...basicInfo.content,
...dateTime.content
};
return {
content: [
{
type: 'text',
text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
}
]
};
} else if (result.action === 'decline') {
} catch (error) {
return {
content: [{ type: 'text', text: 'Address update cancelled by user.' }]
};
} else {
return {
content: [{ type: 'text', text: 'Address update was cancelled.' }]
content: [
{
type: 'text',
text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
);
/**
* Example 3: Collecting address information
* Demonstrates validation with patterns and optional fields
*/
mcpServer.registerTool(
'update_shipping_address',
{
description: 'Update shipping address with validation'
},
async () => {
try {
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your shipping address:',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Recipient name',
minLength: 1
},
street: {
type: 'string',
title: 'Street Address',
minLength: 1
},
city: {
type: 'string',
title: 'City',
minLength: 1
},
state: {
type: 'string',
title: 'State/Province',
minLength: 2,
maxLength: 2
},
zipCode: {
type: 'string',
title: 'ZIP/Postal Code',
description: '5-digit ZIP code'
},
phone: {
type: 'string',
title: 'Phone Number (optional)',
description: 'Contact phone number'
}
},
required: ['name', 'street', 'city', 'state', 'zipCode']
}
});
if (result.action === 'accept' && result.content) {
return {
content: [
{
type: 'text',
text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [{ type: 'text', text: 'Address update cancelled by user.' }]
};
} else {
return {
content: [{ type: 'text', text: 'Address update was cancelled.' }]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
return mcpServer;
};
async function main() {
const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
@ -354,7 +358,8 @@ async function main() {
}
};
// Connect the transport to the MCP server BEFORE handling the request
// Connect a fresh MCP server to the transport BEFORE handling the request
const mcpServer = getServer();
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);

View file

@ -23,64 +23,68 @@ import type { Request, Response } from 'express';
import { InMemoryEventStore } from './inMemoryEventStore.js';
// Create the MCP server
const server = new McpServer(
{
name: 'sse-polling-example',
version: '1.0.0'
},
{
capabilities: { logging: {} }
}
);
// Register a long-running tool that demonstrates server-initiated disconnect
server.registerTool(
'long-task',
{
description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.'
},
async (ctx): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
console.log(`[${ctx.sessionId}] Starting long-task...`);
// Send first progress notification
await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...');
await sleep(1000);
// Send second progress notification
await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...');
await sleep(1000);
// Server decides to disconnect the client to free resources
// Client will reconnect via GET with Last-Event-ID after the transport's retryInterval
// Use ctx.http?.closeSSE callback - available when eventStore is configured
if (ctx.http?.closeSSE) {
console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`);
ctx.http?.closeSSE();
// Create a fresh MCP server per client connection to avoid shared state between clients
const getServer = () => {
const server = new McpServer(
{
name: 'sse-polling-example',
version: '1.0.0'
},
{
capabilities: { logging: {} }
}
);
// Continue processing while client is disconnected
// Events are stored in eventStore and will be replayed on reconnect
await sleep(500);
await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...');
// Register a long-running tool that demonstrates server-initiated disconnect
server.registerTool(
'long-task',
{
description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.'
},
async (ctx): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await sleep(500);
await ctx.mcpReq.log('info', 'Progress: 100% - Complete!');
console.log(`[${ctx.sessionId}] Starting long-task...`);
console.log(`[${ctx.sessionId}] Task complete`);
// Send first progress notification
await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...');
await sleep(1000);
return {
content: [
{
type: 'text',
text: 'Long task completed successfully!'
}
]
};
}
);
// Send second progress notification
await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...');
await sleep(1000);
// Server decides to disconnect the client to free resources
// Client will reconnect via GET with Last-Event-ID after the transport's retryInterval
// Use ctx.http?.closeSSE callback - available when eventStore is configured
if (ctx.http?.closeSSE) {
console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`);
ctx.http?.closeSSE();
}
// Continue processing while client is disconnected
// Events are stored in eventStore and will be replayed on reconnect
await sleep(500);
await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...');
await sleep(500);
await ctx.mcpReq.log('info', 'Progress: 100% - Complete!');
console.log(`[${ctx.sessionId}] Task complete`);
return {
content: [
{
type: 'text',
text: 'Long task completed successfully!'
}
]
};
}
);
return server;
};
// Set up Express app
const app = createMcpExpressApp();
@ -110,7 +114,8 @@ app.all('/mcp', async (req: Request, res: Response) => {
}
});
// Connect the MCP server to the transport
// Connect a fresh MCP server to the transport
const server = getServer();
await server.connect(transport);
}

View file

@ -6,16 +6,8 @@ import type { ReadResourceResult } from '@modelcontextprotocol/server';
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
// Create an MCP server with implementation details
const server = new McpServer({
name: 'resource-list-changed-notification-server',
version: '1.0.0'
});
// Store transports by session ID to send notifications
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
const addResource = (name: string, content: string) => {
// Helper to register a dynamic resource on a given server instance
const addResource = (server: McpServer, name: string, content: string) => {
const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`;
server.registerResource(
name,
@ -29,11 +21,28 @@ const addResource = (name: string, content: string) => {
);
};
addResource('example-resource', 'Initial content for example-resource');
// Create a fresh MCP server per client connection to avoid shared state between clients
const getServer = () => {
const server = new McpServer({
name: 'resource-list-changed-notification-server',
version: '1.0.0'
});
addResource(server, 'example-resource', 'Initial content for example-resource');
return server;
};
// Store transports and their associated servers by session ID
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};
// Periodically add a new resource to all active server instances for testing
const resourceChangeInterval = setInterval(() => {
const name = randomUUID();
addResource(name, `Content for ${name}`);
for (const sessionId in servers) {
addResource(servers[sessionId]!, name, `Content for ${name}`);
}
}, 5000); // Change resources every 5 seconds for testing
const app = createMcpExpressApp();
@ -49,18 +58,29 @@ app.post('/mcp', async (req: Request, res: Response) => {
// Reuse existing transport
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request
// New initialization request - create a fresh server for this client
const server = getServer();
transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sessionId => {
// Store the transport by session ID when session is initialized
// Store the transport and server by session ID when session is initialized
// This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
servers[sessionId] = server;
}
});
// Connect the transport to the MCP server
// Clean up both maps when the transport closes
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
delete transports[sid];
delete servers[sid];
}
};
// Connect the fresh MCP server to the transport
await server.connect(transport);
// Handle the request - the onsessioninitialized callback will store the transport
@ -124,6 +144,18 @@ app.listen(PORT, error => {
process.on('SIGINT', async () => {
console.log('Shutting down server...');
clearInterval(resourceChangeInterval);
await server.close();
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transports[sessionId]!.close();
delete transports[sessionId];
delete servers[sessionId];
} catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log('Server shutdown complete');
process.exit(0);
});