fix(examples): create fresh McpServer per client in three examples (#1610)
This commit is contained in:
parent
3391bfb57f
commit
52186d1884
3 changed files with 378 additions and 336 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue