initial commit
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { ToolDef } from "./types";
|
||||
import McpSession from "./mcpConnection";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types";
|
||||
import { pingTool, testAddTool, testRemoveTool, listToolsTool, invokeToolTool } from "./mcpTools";
|
||||
|
||||
export class Server {
|
||||
private readonly app: FastifyInstance;
|
||||
private readonly port: number;
|
||||
private readonly sessions: McpSession[] = [];
|
||||
public readonly tools: Record<string, ToolDef> = {};
|
||||
|
||||
constructor(port: number = Number(process.env.PORT ?? 3000)) {
|
||||
this.port = port;
|
||||
this.app = Fastify({
|
||||
trustProxy: true,
|
||||
logger: {
|
||||
serializers: {
|
||||
req(request) {
|
||||
return {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
host: request.headers.host,
|
||||
remoteAddress: request.ip,
|
||||
headers: request.headers, // <-- full header set
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// logger: {
|
||||
// level: process.env.LOG_LEVEL ?? "info",
|
||||
// ...(process.env.NODE_ENV !== "production"
|
||||
// ? {
|
||||
// transport: {
|
||||
// target: "pino-pretty",
|
||||
// options: { colorize: true, translateTime: "HH:MM:ss.l", ignore: "pid,hostname" },
|
||||
// },
|
||||
// }
|
||||
// : {}),
|
||||
// },
|
||||
});
|
||||
this.registerLogging();
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
private registerLogging(): void {
|
||||
// onResponse is skipped for hijacked replies (the /mcp routes hijack to
|
||||
// hand the raw response to the MCP SDK), so listen on the raw response's
|
||||
// "finish" event instead — that fires regardless of hijack status.
|
||||
this.app.addHook("onRequest", async (request, reply) => {
|
||||
const start = process.hrtime.bigint();
|
||||
request.log.info({ method: request.method, url: request.url, ip: request.ip }, "request received");
|
||||
reply.raw.on("finish", () => {
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
request.log.info(
|
||||
{
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: reply.raw.statusCode,
|
||||
ms: Number(ms.toFixed(1)),
|
||||
},
|
||||
"request completed",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public getTool(name: string): ToolDef {
|
||||
return this.tools[name];
|
||||
}
|
||||
|
||||
public registerTools(...tools: ToolDef[]): void {
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!this.getTool(tool.title))
|
||||
this.tools[tool.title] = tool;
|
||||
}
|
||||
|
||||
this.emitToolListChange();
|
||||
}
|
||||
|
||||
public unregisterTools(...names: string[]): void {
|
||||
for (const name of names)
|
||||
delete (this.tools[name]);
|
||||
|
||||
this.emitToolListChange();
|
||||
}
|
||||
|
||||
private emitToolListChange() {
|
||||
for (const session of Object.values(this.sessions))
|
||||
session.handleToolListChange(Object.values(this.tools));
|
||||
}
|
||||
|
||||
public getSession(sessionId: string): McpSession | undefined {
|
||||
return this.sessions.find((s) => s.sessionId === sessionId);
|
||||
}
|
||||
|
||||
// Stateful Streamable HTTP transport, keyed by session id. Fastify's
|
||||
// request/reply are hijacked so the MCP SDK can write directly to the
|
||||
// underlying Node req/res it expects.
|
||||
private registerRoutes(): void {
|
||||
this.app.post("/mcp", async (request, reply) => {
|
||||
|
||||
let session: McpSession | undefined;
|
||||
|
||||
const sessionId = request.headers["mcp-session-id"] as string | undefined;
|
||||
if (!sessionId) {
|
||||
|
||||
console.log("[mcp] typeof body:", typeof request.body,
|
||||
"isInit:", isInitializeRequest(request.body),
|
||||
"session:", request.headers["mcp-session-id"]);
|
||||
|
||||
if (!isInitializeRequest(request.body))
|
||||
throw new Error('Only initialize request is allowed to have no mcp-session-id header');
|
||||
session = new McpSession();
|
||||
session.handleToolListChange(Object.values(this.tools), false);
|
||||
await session.init();
|
||||
this.sessions.push(session);
|
||||
}
|
||||
else {
|
||||
session = this.getSession(sessionId);
|
||||
if (!session)
|
||||
return reply.status(404).send("Not found");
|
||||
}
|
||||
|
||||
session.handleRequest(request, reply);
|
||||
});
|
||||
|
||||
// GET = open the SSE stream the server pushes notifications through
|
||||
this.app.get("/mcp", async (request, reply) => {
|
||||
const sessionId = request.headers["mcp-session-id"] as string | undefined;
|
||||
if (!sessionId)
|
||||
throw new Error('mcp-session-id header is missing');
|
||||
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session)
|
||||
return reply.status(404).send("Not found");
|
||||
|
||||
await session.handleRequest(request, reply);
|
||||
});
|
||||
|
||||
// DELETE = client terminates the session
|
||||
this.app.delete("/mcp", async (request, reply) => {
|
||||
const sessionId = request.headers["mcp-session-id"] as string | undefined;
|
||||
if (!sessionId)
|
||||
throw new Error('mcp-session-id header is missing');
|
||||
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session)
|
||||
return reply.status(404).send("Not found");
|
||||
|
||||
await session.handleRequest(request, reply);
|
||||
});
|
||||
|
||||
this.app.get("/healthz", async () => ({ ok: true, sessions: Object.keys(this.sessions).length }));
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.app.listen({ port: this.port, host: "0.0.0.0" });
|
||||
this.app.log.info(`MCP ping server listening on http://0.0.0.0:${this.port}/mcp`);
|
||||
}
|
||||
}
|
||||
|
||||
export const server = new Server();
|
||||
server.registerTools(pingTool, listToolsTool, invokeToolTool, testAddTool, testRemoveTool);
|
||||
Reference in New Issue
Block a user