Release v0.2.0
This commit is contained in:
+231
-25
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const { safeStringify } = require('./utils');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
@@ -14,14 +15,26 @@ const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
'2024-11-05',
|
||||
];
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) {
|
||||
function responseHeaders(protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
return {
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...extraHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'MCP-Protocol-Version': protocolVersion,
|
||||
...responseHeaders(protocolVersion, extraHeaders),
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function empty(response, statusCode, protocolVersion = MCP_PROTOCOL_VERSION, extraHeaders = {}) {
|
||||
response.writeHead(statusCode, responseHeaders(protocolVersion, extraHeaders));
|
||||
response.end();
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return [
|
||||
@@ -68,12 +81,15 @@ class McpServer {
|
||||
this.resourceProvider = options.resourceProvider;
|
||||
this.promptProvider = options.promptProvider;
|
||||
this.interactionLog = options.interactionLog;
|
||||
this.runtimeLog = options.runtimeLog;
|
||||
this.serverName = options.serverName;
|
||||
this.serverVersion = options.serverVersion;
|
||||
this.server = null;
|
||||
this.actualPort = null;
|
||||
this.portFallbackInfo = null;
|
||||
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
|
||||
this.enableSessions = Boolean(this.config && this.config.enableSessions);
|
||||
this.sessions = new Set();
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
@@ -98,9 +114,24 @@ class McpServer {
|
||||
return this.portFallbackInfo;
|
||||
}
|
||||
|
||||
log(level, message) {
|
||||
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
|
||||
this.runtimeLog.add(level, message);
|
||||
}
|
||||
|
||||
const output = `${LOG_PREFIX} ${message}`;
|
||||
if (level === 'error') {
|
||||
console.error(output);
|
||||
} else if (level === 'warn') {
|
||||
console.warn(output);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||
this.log('info', 'Start skipped: already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,20 +141,34 @@ class McpServer {
|
||||
const requestHandler = async (request, response) => {
|
||||
try {
|
||||
if (request.method === 'GET' && request.url === '/health') {
|
||||
console.log(`${LOG_PREFIX} GET /health`);
|
||||
this.log('info', 'GET /health');
|
||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (!this.isAllowedOrigin(request)) {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: invalid Origin header.`);
|
||||
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
return this.handleDelete(request, response);
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: SSE GET streams are not supported.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed: SSE streams are not supported' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
this.log('warn', `Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const acceptHeaderError = this.validateAcceptHeader(request);
|
||||
if (acceptHeaderError) {
|
||||
return json(response, 406, acceptHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
if (!body) {
|
||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
|
||||
@@ -137,24 +182,52 @@ class McpServer {
|
||||
}
|
||||
|
||||
if (rpc && rpc.method) {
|
||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||
this.log('info', `RPC ${rpc.method}`);
|
||||
}
|
||||
|
||||
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
|
||||
if (protocolHeaderError) {
|
||||
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
|
||||
}
|
||||
const responseProtocolVersion = this.getProtocolVersionForResponse(request, rpc);
|
||||
|
||||
const sessionError = this.validateSession(request, rpc);
|
||||
if (sessionError) {
|
||||
return json(response, sessionError.statusCode, sessionError.error, responseProtocolVersion);
|
||||
}
|
||||
|
||||
const messageType = this.classifyJsonRpcMessage(rpc);
|
||||
if (messageType === 'response') {
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType === 'notification') {
|
||||
const notificationError = this.handleRpcNotification(rpc);
|
||||
if (notificationError) {
|
||||
return json(response, 400, notificationError, responseProtocolVersion);
|
||||
}
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
if (messageType !== 'request') {
|
||||
return json(response, 400, this.createError(rpc && rpc.id, -32600, 'Invalid Request'), responseProtocolVersion);
|
||||
}
|
||||
|
||||
const result = await this.handleRpcRequest(rpc);
|
||||
if (result == null) {
|
||||
response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion });
|
||||
response.end();
|
||||
return;
|
||||
return empty(response, 202, responseProtocolVersion);
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.negotiatedProtocolVersion);
|
||||
const extraHeaders = {};
|
||||
if (this.enableSessions && rpc.method === 'initialize' && result && !result.error) {
|
||||
const sessionId = this.createSessionId();
|
||||
this.sessions.add(sessionId);
|
||||
extraHeaders['Mcp-Session-Id'] = sessionId;
|
||||
}
|
||||
|
||||
return json(response, 200, result, this.getProtocolVersionForResponse(request, rpc), extraHeaders);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
||||
this.log('error', `Request handling failed: ${error.message}`);
|
||||
const statusCode = error.statusCode || 500;
|
||||
const rpcCode = error.rpcCode || -32603;
|
||||
const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message;
|
||||
@@ -167,7 +240,7 @@ class McpServer {
|
||||
let lastError = null;
|
||||
|
||||
while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
this.log('info', `Creating HTTP server on ${this.config.host}:${port}...`);
|
||||
const candidate = http.createServer(requestHandler);
|
||||
|
||||
try {
|
||||
@@ -177,27 +250,27 @@ class McpServer {
|
||||
? candidate.address().port
|
||||
: port;
|
||||
|
||||
if (this.actualPort !== this.config.port) {
|
||||
if (this.config.port !== 0 && this.actualPort !== this.config.port) {
|
||||
this.portFallbackInfo = {
|
||||
requestedPort: this.config.port,
|
||||
actualPort: this.actualPort,
|
||||
attempts: attempt,
|
||||
};
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
|
||||
`Fell back to ${this.actualPort}.`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${this.config.port} was unavailable. Fell back to ${this.actualPort}.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.actualPort}/`);
|
||||
this.log('info', `Listening on http://${this.config.host}:${this.actualPort}/`);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
|
||||
const nextPort = port + 1;
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Port ${port} is already in use. ` +
|
||||
`Trying fallback port ${nextPort}...`
|
||||
this.log(
|
||||
'warn',
|
||||
`Port ${port} is already in use. Trying fallback port ${nextPort}...`
|
||||
);
|
||||
port = nextPort;
|
||||
attempt += 1;
|
||||
@@ -217,11 +290,11 @@ class McpServer {
|
||||
|
||||
async stop() {
|
||||
if (!this.server) {
|
||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
||||
this.log('info', 'Stop skipped: server object is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||
this.log('info', 'Closing HTTP server...');
|
||||
const active = this.server;
|
||||
this.server = null;
|
||||
this.actualPort = null;
|
||||
@@ -229,11 +302,11 @@ class McpServer {
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => {
|
||||
if (error) {
|
||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
||||
this.log('error', `Close failed: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
||||
this.log('info', 'HTTP server closed.');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -295,6 +368,35 @@ class McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
validateAcceptHeader(request) {
|
||||
const header = request.headers && request.headers.accept;
|
||||
if (!header) {
|
||||
return this.createError(
|
||||
null,
|
||||
-32600,
|
||||
'Missing Accept header. Streamable HTTP clients must accept application/json and text/event-stream.'
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = String(Array.isArray(header) ? header.join(',') : header)
|
||||
.split(',')
|
||||
.map((item) => item.split(';')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const hasWildcard = tokens.includes('*/*');
|
||||
const hasJson = hasWildcard || tokens.includes('application/json') || tokens.includes('application/*');
|
||||
const hasSse = hasWildcard || tokens.includes('text/event-stream') || tokens.includes('text/*');
|
||||
|
||||
if (!hasJson || !hasSse) {
|
||||
return this.createError(
|
||||
null,
|
||||
-32600,
|
||||
'Invalid Accept header. Streamable HTTP clients must accept both application/json and text/event-stream.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
validateProtocolVersionHeader(request, rpc) {
|
||||
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||
if (!header || (rpc && rpc.method === 'initialize')) {
|
||||
@@ -313,6 +415,110 @@ class McpServer {
|
||||
return null;
|
||||
}
|
||||
|
||||
getProtocolVersionForResponse(request, rpc) {
|
||||
if (rpc && rpc.method === 'initialize') {
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
const header = request.headers && request.headers['mcp-protocol-version'];
|
||||
const version = Array.isArray(header) ? header[0] : header ? String(header) : '';
|
||||
if (SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
|
||||
return version;
|
||||
}
|
||||
return this.negotiatedProtocolVersion;
|
||||
}
|
||||
|
||||
validateSession(request, rpc) {
|
||||
if (!this.enableSessions || (rpc && rpc.method === 'initialize')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: this.createError(rpc && rpc.id, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
};
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
error: this.createError(rpc && rpc.id, -32001, 'Unknown or expired MCP session.'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSessionId(request) {
|
||||
const value = request.headers && (request.headers['mcp-session-id'] || request.headers['Mcp-Session-Id']);
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] || '';
|
||||
}
|
||||
return value ? String(value) : '';
|
||||
}
|
||||
|
||||
createSessionId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
handleDelete(request, response) {
|
||||
if (!this.enableSessions) {
|
||||
return json(response, 405, { error: 'Method Not Allowed: MCP sessions are disabled' }, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
const sessionId = this.getSessionId(request);
|
||||
if (!sessionId) {
|
||||
return json(
|
||||
response,
|
||||
400,
|
||||
this.createError(null, -32600, 'Missing Mcp-Session-Id header.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return json(
|
||||
response,
|
||||
404,
|
||||
this.createError(null, -32001, 'Unknown or expired MCP session.'),
|
||||
this.negotiatedProtocolVersion
|
||||
);
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
return empty(response, 202, this.negotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
classifyJsonRpcMessage(message) {
|
||||
if (!message || message.jsonrpc !== '2.0') {
|
||||
return 'invalid';
|
||||
}
|
||||
if (typeof message.method === 'string') {
|
||||
return Object.prototype.hasOwnProperty.call(message, 'id') ? 'request' : 'notification';
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(message, 'id') &&
|
||||
(Object.prototype.hasOwnProperty.call(message, 'result') || Object.prototype.hasOwnProperty.call(message, 'error'))
|
||||
) {
|
||||
return 'response';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
handleRpcNotification(notification) {
|
||||
if (!notification || notification.jsonrpc !== '2.0' || typeof notification.method !== 'string') {
|
||||
return this.createError(null, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
if (notification.method.startsWith('notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createError(null, -32601, `Notification method not found: ${notification.method}`);
|
||||
}
|
||||
|
||||
async handleRpcRequest(request) {
|
||||
if (!request || request.jsonrpc !== '2.0') {
|
||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||
|
||||
Reference in New Issue
Block a user