feat: add MCP server templates and deployment infrastructure
Introduce a Helm-chart-like template system for MCP servers. Templates are YAML files in templates/ that get seeded into the DB on startup. Users can browse them with `mcpctl get templates`, inspect with `mcpctl describe template`, and instantiate with `mcpctl create server --from-template=`. Also adds Portainer deployment scripts, mcplocal systemd service, Streamable HTTP MCP endpoint, and RPM packaging for mcpctl-local. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ export { loadHttpConfig } from './config.js';
|
||||
export type { HttpConfig } from './config.js';
|
||||
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
|
||||
export { registerProxyRoutes } from './routes/proxy.js';
|
||||
export { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
|
||||
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Streamable HTTP MCP protocol endpoint.
|
||||
*
|
||||
* Exposes the McpRouter over HTTP at /mcp so Claude Code can connect
|
||||
* via `{ "type": "http", "url": "http://localhost:3200/mcp" }` in .mcp.json.
|
||||
*
|
||||
* Each client session gets its own StreamableHTTPServerTransport, but all
|
||||
* share the same McpRouter (and therefore the same upstream connections).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { JsonRpcRequest } from '../types.js';
|
||||
|
||||
interface SessionEntry {
|
||||
transport: StreamableHTTPServerTransport;
|
||||
}
|
||||
|
||||
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
|
||||
const sessions = new Map<string, SessionEntry>();
|
||||
|
||||
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
|
||||
app.post('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
// Existing session
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
// Fastify must not send its own response — the transport already did
|
||||
reply.hijack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId && !sessions.has(sessionId)) {
|
||||
// Unknown session
|
||||
reply.code(404).send({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// New session — no session ID header
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessions.set(id, { transport });
|
||||
},
|
||||
});
|
||||
|
||||
// Wire transport messages to the router
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
// The transport sends us JSON-RPC messages; route them through McpRouter
|
||||
if ('method' in message && 'id' in message) {
|
||||
const response = await router.route(message as unknown as JsonRpcRequest);
|
||||
await transport.send(response as unknown as JSONRPCMessage);
|
||||
}
|
||||
// Notifications (no id) are ignored — router doesn't handle inbound notifications
|
||||
};
|
||||
|
||||
transport.onclose = () => {
|
||||
const id = transport.sessionId;
|
||||
if (id) {
|
||||
sessions.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// GET /mcp — SSE stream for server-initiated notifications
|
||||
app.get('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// DELETE /mcp — Session cleanup
|
||||
app.delete('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
sessions.delete(sessionId);
|
||||
reply.hijack();
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { APP_VERSION } from '@mcpctl/shared';
|
||||
import type { HttpConfig } from './config.js';
|
||||
import { McpdClient } from './mcpd-client.js';
|
||||
import { registerProxyRoutes } from './routes/proxy.js';
|
||||
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { HealthMonitor } from '../health.js';
|
||||
import type { TieredHealthMonitor } from '../health/tiered.js';
|
||||
@@ -81,5 +82,8 @@ export async function createHttpServer(
|
||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||
registerProxyRoutes(app, mcpdClient);
|
||||
|
||||
// Streamable HTTP MCP protocol endpoint at /mcp
|
||||
registerMcpEndpoint(app, deps.router);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,10 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
|
||||
}
|
||||
|
||||
// Run when executed directly
|
||||
const isMain = process.argv[1]?.endsWith('main.js') || process.argv[1]?.endsWith('main.ts');
|
||||
const isMain =
|
||||
process.argv[1]?.endsWith('main.js') ||
|
||||
process.argv[1]?.endsWith('main.ts') ||
|
||||
process.argv[1]?.endsWith('mcpctl-local');
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err}\n`);
|
||||
|
||||
Reference in New Issue
Block a user