feat: add MCP server templates and deployment infrastructure
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

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:
Michal
2026-02-22 22:24:35 +00:00
parent 1e8847bb63
commit d58e6e153f
46 changed files with 1299 additions and 338 deletions

View File

@@ -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';

View 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();
});
}

View File

@@ -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;
}

View File

@@ -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`);