2026-03-09 15:35:29 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* Copyright (c) 2026 by Christian Kellner.
|
|
|
|
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
|
|
|
|
import { createMcpServer } from './mcpAdapter.js';
|
|
|
|
|
|
import { authenticateRequest } from './mcpAuthentication.js';
|
2026-03-09 16:26:53 +01:00
|
|
|
|
import logger from '../services/logger.js';
|
2026-03-09 15:35:29 +01:00
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Active transports keyed by session id.
|
|
|
|
|
|
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
const sessions = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {string|undefined} sessionId
|
|
|
|
|
|
* @param {{ userId: string }} auth
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getOrCreateSession(sessionId, auth) {
|
|
|
|
|
|
if (sessionId && sessions.has(sessionId)) {
|
|
|
|
|
|
return sessions.get(sessionId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const transport = new StreamableHTTPServerTransport({
|
|
|
|
|
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
|
|
|
|
onsessioninitialized: (sid) => {
|
|
|
|
|
|
sessions.set(sid, entry);
|
|
|
|
|
|
logger.debug(`MCP session created: ${sid}`);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const server = createMcpServer();
|
|
|
|
|
|
const entry = { server, transport, userId: auth.userId };
|
|
|
|
|
|
|
|
|
|
|
|
transport.onclose = () => {
|
|
|
|
|
|
const sid = transport.sessionId;
|
|
|
|
|
|
if (sid) {
|
|
|
|
|
|
sessions.delete(sid);
|
|
|
|
|
|
logger.debug(`MCP session closed: ${sid}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return entry;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-27 16:56:04 +02:00
|
|
|
|
* Register MCP Streamable HTTP routes on a fastify instance.
|
2026-03-09 15:35:29 +01:00
|
|
|
|
*
|
2026-04-27 16:56:04 +02:00
|
|
|
|
* POST /api/mcp – JSON-RPC messages
|
|
|
|
|
|
* GET /api/mcp – SSE stream for server-initiated notifications
|
|
|
|
|
|
* DELETE /api/mcp – session termination
|
2026-03-09 15:35:29 +01:00
|
|
|
|
*
|
|
|
|
|
|
* All endpoints require a valid Bearer token in the Authorization header.
|
|
|
|
|
|
*
|
2026-04-27 16:56:04 +02:00
|
|
|
|
* @param {import('fastify').FastifyInstance} fastify
|
2026-03-09 15:35:29 +01:00
|
|
|
|
*/
|
2026-04-27 16:56:04 +02:00
|
|
|
|
export function registerMcpRoutes(fastify) {
|
|
|
|
|
|
fastify.post('/api/mcp', async (request, reply) => {
|
|
|
|
|
|
const auth = authenticateRequest(request.raw);
|
2026-03-09 15:35:29 +01:00
|
|
|
|
if (!auth) {
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
2026-03-09 15:35:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
const sessionId = request.raw.headers['mcp-session-id'];
|
2026-03-09 15:35:29 +01:00
|
|
|
|
const { server, transport } = getOrCreateSession(sessionId, auth);
|
|
|
|
|
|
|
|
|
|
|
|
if (!transport.onmessage) {
|
|
|
|
|
|
await server.connect(transport);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
request.raw.auth = { userId: auth.userId };
|
2026-03-09 15:35:29 +01:00
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
reply.hijack();
|
|
|
|
|
|
await transport.handleRequest(request.raw, reply.raw, request.body);
|
2026-03-09 15:35:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
fastify.get('/api/mcp', async (request, reply) => {
|
|
|
|
|
|
const auth = authenticateRequest(request.raw);
|
2026-03-09 15:35:29 +01:00
|
|
|
|
if (!auth) {
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
2026-03-09 15:35:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
const sessionId = request.raw.headers['mcp-session-id'];
|
2026-03-09 15:35:29 +01:00
|
|
|
|
if (!sessionId || !sessions.has(sessionId)) {
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
2026-03-09 15:35:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { transport } = sessions.get(sessionId);
|
2026-04-27 16:56:04 +02:00
|
|
|
|
reply.hijack();
|
|
|
|
|
|
await transport.handleRequest(request.raw, reply.raw);
|
2026-03-09 15:35:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
fastify.delete('/api/mcp', async (request, reply) => {
|
|
|
|
|
|
const auth = authenticateRequest(request.raw);
|
2026-03-09 15:35:29 +01:00
|
|
|
|
if (!auth) {
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
2026-03-09 15:35:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 16:56:04 +02:00
|
|
|
|
const sessionId = request.raw.headers['mcp-session-id'];
|
2026-03-09 15:35:29 +01:00
|
|
|
|
if (!sessionId || !sessions.has(sessionId)) {
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return reply.code(404).send({ error: 'Session not found.' });
|
2026-03-09 15:35:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { transport } = sessions.get(sessionId);
|
|
|
|
|
|
await transport.close();
|
|
|
|
|
|
sessions.delete(sessionId);
|
2026-04-27 16:56:04 +02:00
|
|
|
|
return { ok: true };
|
2026-03-09 15:35:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
|
|
|
|
|
}
|