avancement planning
This commit is contained in:
Generated
Vendored
+174
-121
@@ -1,78 +1,93 @@
|
||||
import express from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
|
||||
import { isInitializeRequest } from '../../types.js';
|
||||
import { createMcpExpressApp } from '../../server/express.js';
|
||||
import { ElicitResultSchema, isInitializeRequest } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../experimental/tasks/stores/in-memory.js';
|
||||
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
|
||||
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
|
||||
import cors from 'cors';
|
||||
import { checkResourceAllowed } from '../../shared/auth-utils.js';
|
||||
// Check for OAuth flag
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
||||
// Create shared task store for demonstration
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
const server = new McpServer({
|
||||
name: 'simple-streamable-http-server',
|
||||
version: '1.0.0'
|
||||
}, { capabilities: { logging: {} } });
|
||||
version: '1.0.0',
|
||||
icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }],
|
||||
websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk'
|
||||
}, {
|
||||
capabilities: { logging: {}, tasks: { requests: { tools: { call: {} } } } },
|
||||
taskStore, // Enable task support
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
});
|
||||
// Register a simple tool that returns a greeting
|
||||
server.registerTool('greet', {
|
||||
title: 'Greeting Tool', // Display name for UI
|
||||
description: 'A simple greeting tool',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
name: z.string().describe('Name to greet')
|
||||
}
|
||||
}, async ({ name }) => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Hello, ${name}!`,
|
||||
},
|
||||
],
|
||||
text: `Hello, ${name}!`
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
// Register a tool that sends multiple greetings with notifications (with annotations)
|
||||
server.tool('multi-greet', 'A tool that sends different greetings with delays between them', {
|
||||
name: z.string().describe('Name to greet'),
|
||||
}, {
|
||||
title: 'Multiple Greeting Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}, async ({ name }, { sendNotification }) => {
|
||||
server.registerTool('multi-greet', {
|
||||
description: 'A tool that sends different greetings with delays between them',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Name to greet')
|
||||
},
|
||||
annotations: {
|
||||
title: 'Multiple Greeting Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}, async ({ name }, extra) => {
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: { level: "debug", data: `Starting multi-greet for ${name}` }
|
||||
});
|
||||
await server.sendLoggingMessage({
|
||||
level: 'debug',
|
||||
data: `Starting multi-greet for ${name}`
|
||||
}, extra.sessionId);
|
||||
await sleep(1000); // Wait 1 second before first greeting
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: { level: "info", data: `Sending first greeting to ${name}` }
|
||||
});
|
||||
await server.sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: `Sending first greeting to ${name}`
|
||||
}, extra.sessionId);
|
||||
await sleep(1000); // Wait another second before second greeting
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: { level: "info", data: `Sending second greeting to ${name}` }
|
||||
});
|
||||
await server.sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: `Sending second greeting to ${name}`
|
||||
}, extra.sessionId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Good morning, ${name}!`,
|
||||
text: `Good morning, ${name}!`
|
||||
}
|
||||
],
|
||||
]
|
||||
};
|
||||
});
|
||||
// Register a tool that demonstrates elicitation (user input collection)
|
||||
// Register a tool that demonstrates form elicitation (user input collection with a schema)
|
||||
// This creates a closure that captures the server instance
|
||||
server.tool('collect-user-info', 'A tool that collects user information through elicitation', {
|
||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
|
||||
}, async ({ infoType }) => {
|
||||
server.registerTool('collect-user-info', {
|
||||
description: 'A tool that collects user information through form elicitation',
|
||||
inputSchema: {
|
||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect')
|
||||
}
|
||||
}, async ({ infoType }, extra) => {
|
||||
let message;
|
||||
let requestedSchema;
|
||||
switch (infoType) {
|
||||
@@ -84,21 +99,21 @@ const getServer = () => {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Full Name',
|
||||
description: 'Your full name',
|
||||
description: 'Your full name'
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
title: 'Email Address',
|
||||
description: 'Your email address',
|
||||
format: 'email',
|
||||
format: 'email'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
title: 'Phone Number',
|
||||
description: 'Your phone number (optional)',
|
||||
},
|
||||
description: 'Your phone number (optional)'
|
||||
}
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
required: ['name', 'email']
|
||||
};
|
||||
break;
|
||||
case 'preferences':
|
||||
@@ -111,23 +126,23 @@ const getServer = () => {
|
||||
title: 'Theme',
|
||||
description: 'Choose your preferred theme',
|
||||
enum: ['light', 'dark', 'auto'],
|
||||
enumNames: ['Light', 'Dark', 'Auto'],
|
||||
enumNames: ['Light', 'Dark', 'Auto']
|
||||
},
|
||||
notifications: {
|
||||
type: 'boolean',
|
||||
title: 'Enable Notifications',
|
||||
description: 'Would you like to receive notifications?',
|
||||
default: true,
|
||||
default: true
|
||||
},
|
||||
frequency: {
|
||||
type: 'string',
|
||||
title: 'Notification Frequency',
|
||||
description: 'How often would you like notifications?',
|
||||
enum: ['daily', 'weekly', 'monthly'],
|
||||
enumNames: ['Daily', 'Weekly', 'Monthly'],
|
||||
},
|
||||
enumNames: ['Daily', 'Weekly', 'Monthly']
|
||||
}
|
||||
},
|
||||
required: ['theme'],
|
||||
required: ['theme']
|
||||
};
|
||||
break;
|
||||
case 'feedback':
|
||||
@@ -140,40 +155,44 @@ const getServer = () => {
|
||||
title: 'Rating',
|
||||
description: 'Rate your experience (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
maximum: 5
|
||||
},
|
||||
comments: {
|
||||
type: 'string',
|
||||
title: 'Comments',
|
||||
description: 'Additional comments (optional)',
|
||||
maxLength: 500,
|
||||
maxLength: 500
|
||||
},
|
||||
recommend: {
|
||||
type: 'boolean',
|
||||
title: 'Would you recommend this?',
|
||||
description: 'Would you recommend this to others?',
|
||||
},
|
||||
description: 'Would you recommend this to others?'
|
||||
}
|
||||
},
|
||||
required: ['rating', 'recommend'],
|
||||
required: ['rating', 'recommend']
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown info type: ${infoType}`);
|
||||
}
|
||||
try {
|
||||
// Use the underlying server instance to elicit input from the client
|
||||
const result = await server.server.elicitInput({
|
||||
message,
|
||||
requestedSchema,
|
||||
});
|
||||
// Use sendRequest through the extra parameter to elicit input
|
||||
const result = await extra.sendRequest({
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message,
|
||||
requestedSchema
|
||||
}
|
||||
}, ElicitResultSchema);
|
||||
if (result.action === 'accept') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
|
||||
},
|
||||
],
|
||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
else if (result.action === 'decline') {
|
||||
@@ -181,9 +200,9 @@ const getServer = () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No information was collected. User declined ${infoType} information request.`,
|
||||
},
|
||||
],
|
||||
text: `No information was collected. User declined ${infoType} information request.`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
else {
|
||||
@@ -191,9 +210,9 @@ const getServer = () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Information collection was cancelled by the user.`,
|
||||
},
|
||||
],
|
||||
text: `Information collection was cancelled by the user.`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -202,9 +221,9 @@ const getServer = () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error collecting ${infoType} information: ${error}`,
|
||||
},
|
||||
],
|
||||
text: `Error collecting ${infoType} information: ${error}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -213,8 +232,8 @@ const getServer = () => {
|
||||
title: 'Greeting Template', // Display name for UI
|
||||
description: 'A simple greeting prompt template',
|
||||
argsSchema: {
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
},
|
||||
name: z.string().describe('Name to include in greeting')
|
||||
}
|
||||
}, async ({ name }) => {
|
||||
return {
|
||||
messages: [
|
||||
@@ -222,32 +241,32 @@ const getServer = () => {
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Please greet ${name} in a friendly manner.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
text: `Please greet ${name} in a friendly manner.`
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
// Register a tool specifically for testing resumability
|
||||
server.tool('start-notification-stream', 'Starts sending periodic notifications for testing resumability', {
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
|
||||
}, async ({ interval, count }, { sendNotification }) => {
|
||||
server.registerTool('start-notification-stream', {
|
||||
description: 'Starts sending periodic notifications for testing resumability',
|
||||
inputSchema: {
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50)
|
||||
}
|
||||
}, async ({ interval, count }, extra) => {
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let counter = 0;
|
||||
while (count === 0 || counter < count) {
|
||||
counter++;
|
||||
try {
|
||||
await sendNotification({
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info",
|
||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
||||
}
|
||||
});
|
||||
await server.sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
||||
}, extra.sessionId);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
console.error('Error sending notification:', error);
|
||||
}
|
||||
// Wait for the specified interval
|
||||
await sleep(interval);
|
||||
@@ -256,9 +275,9 @@ const getServer = () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started sending periodic notifications every ${interval}ms`,
|
||||
text: `Started sending periodic notifications every ${interval}ms`
|
||||
}
|
||||
],
|
||||
]
|
||||
};
|
||||
});
|
||||
// Create a simple resource at a fixed URI
|
||||
@@ -271,9 +290,9 @@ const getServer = () => {
|
||||
contents: [
|
||||
{
|
||||
uri: 'https://example.com/greetings/default',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
],
|
||||
text: 'Hello, world!'
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
// Create additional resources for ResourceLink demonstration
|
||||
@@ -286,9 +305,9 @@ const getServer = () => {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file1.txt',
|
||||
text: 'This is the content of file 1',
|
||||
},
|
||||
],
|
||||
text: 'This is the content of file 1'
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
server.registerResource('example-file-2', 'file:///example/file2.txt', {
|
||||
@@ -300,9 +319,9 @@ const getServer = () => {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file2.txt',
|
||||
text: 'This is the content of file 2',
|
||||
},
|
||||
],
|
||||
text: 'This is the content of file 2'
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
// Register a tool that returns ResourceLinks
|
||||
@@ -310,8 +329,8 @@ const getServer = () => {
|
||||
title: 'List Files with ResourceLinks',
|
||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
||||
inputSchema: {
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
||||
},
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links')
|
||||
}
|
||||
}, async ({ includeDescriptions = true }) => {
|
||||
const resourceLinks = [
|
||||
{
|
||||
@@ -340,27 +359,60 @@ const getServer = () => {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the available files as resource links:',
|
||||
text: 'Here are the available files as resource links:'
|
||||
},
|
||||
...resourceLinks,
|
||||
{
|
||||
type: 'text',
|
||||
text: '\nYou can read any of these resources using their URI.',
|
||||
text: '\nYou can read any of these resources using their URI.'
|
||||
}
|
||||
],
|
||||
]
|
||||
};
|
||||
});
|
||||
// Register a long-running tool that demonstrates task execution
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
server.experimental.tasks.registerToolTask('delay', {
|
||||
title: 'Delay',
|
||||
description: 'A simple tool that delays for a specified duration, useful for testing task execution',
|
||||
inputSchema: {
|
||||
duration: z.number().describe('Duration in milliseconds').default(5000)
|
||||
}
|
||||
}, {
|
||||
async createTask({ duration }, { taskStore, taskRequestedTtl }) {
|
||||
// Create the task
|
||||
const task = await taskStore.createTask({
|
||||
ttl: taskRequestedTtl
|
||||
});
|
||||
// Simulate out-of-band work
|
||||
(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Completed ${duration}ms delay`
|
||||
}
|
||||
]
|
||||
});
|
||||
})();
|
||||
// Return CreateTaskResult with the created task
|
||||
return {
|
||||
task
|
||||
};
|
||||
},
|
||||
async getTask(_args, { taskId, taskStore }) {
|
||||
return await taskStore.getTask(taskId);
|
||||
},
|
||||
async getTaskResult(_args, { taskId, taskStore }) {
|
||||
const result = await taskStore.getTaskResult(taskId);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
return server;
|
||||
};
|
||||
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
|
||||
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Allow CORS all domains, expose the Mcp-Session-Id header
|
||||
app.use(cors({
|
||||
origin: '*', // Allow all origins
|
||||
exposedHeaders: ["Mcp-Session-Id"]
|
||||
}));
|
||||
const app = createMcpExpressApp();
|
||||
// Set up OAuth if enabled
|
||||
let authMiddleware = null;
|
||||
if (useOAuth) {
|
||||
@@ -377,14 +429,15 @@ if (useOAuth) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: token
|
||||
}).toString()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid or expired token: ${await response.text()}`);
|
||||
const text = await response.text().catch(() => null);
|
||||
throw new Error(`Invalid or expired token: ${text}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (strictOAuth) {
|
||||
@@ -400,7 +453,7 @@ if (useOAuth) {
|
||||
token,
|
||||
clientId: data.client_id,
|
||||
scopes: data.scope ? data.scope.split(' ') : [],
|
||||
expiresAt: data.exp,
|
||||
expiresAt: data.exp
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -409,12 +462,12 @@ if (useOAuth) {
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server',
|
||||
resourceName: 'MCP Demo Server'
|
||||
}));
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
}
|
||||
// Map to store transports by session ID
|
||||
@@ -443,7 +496,7 @@ const mcpPostHandler = async (req, res) => {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
onsessioninitialized: (sessionId) => {
|
||||
onsessioninitialized: sessionId => {
|
||||
// Store the transport 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}`);
|
||||
@@ -471,9 +524,9 @@ const mcpPostHandler = async (req, res) => {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
message: 'Bad Request: No valid session ID provided'
|
||||
},
|
||||
id: null,
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -488,9 +541,9 @@ const mcpPostHandler = async (req, res) => {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
message: 'Internal server error'
|
||||
},
|
||||
id: null,
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -556,7 +609,7 @@ if (useOAuth && authMiddleware) {
|
||||
else {
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
}
|
||||
app.listen(MCP_PORT, (error) => {
|
||||
app.listen(MCP_PORT, error => {
|
||||
if (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user