initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,10 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "claudeMCP",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.4.0",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"fastify": "^5.2.1",
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"eslint": "^9.21.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
}
|
||||
}
|
||||
Generated
+2528
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,6 @@
|
||||
import { server } from './server';
|
||||
|
||||
server.start().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { ToolDef } from "./types";
|
||||
|
||||
export default class McpSession {
|
||||
private readonly transport: StreamableHTTPServerTransport;
|
||||
private readonly mcp: McpServer;
|
||||
private readonly registeredTools: RegisteredTool[] = [];
|
||||
|
||||
public sessionId: string | undefined; // set when transport says its initialized
|
||||
|
||||
constructor() {
|
||||
this.transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => { // do we really need
|
||||
this.sessionId = id;
|
||||
},
|
||||
});
|
||||
// this.transport.onclose = () => {
|
||||
// };
|
||||
|
||||
this.mcp = this.buildMcpServer();
|
||||
}
|
||||
|
||||
private buildMcpServer(): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "mcp-ping-server", version: "1.0.0" },
|
||||
{ capabilities: { tools: { listChanged: true } } },
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await this.mcp.connect(this.transport);
|
||||
}
|
||||
|
||||
private registerTools(...tools: ToolDef[]) {
|
||||
const toolsToRegister = tools
|
||||
.filter((tool) => !this.registeredTools.find((rtool) => rtool.title === tool.title));
|
||||
const toolsToUnregister = this.registeredTools
|
||||
.filter((rtool) => !tools.find((tool) => rtool.title === tool.title));
|
||||
|
||||
for (const toolToUndegister of toolsToUnregister) {
|
||||
toolToUndegister.remove();
|
||||
const idx = this.registeredTools.indexOf(toolToUndegister);
|
||||
this.registeredTools.splice(idx, 1);
|
||||
}
|
||||
|
||||
this.registeredTools.push(
|
||||
...toolsToRegister
|
||||
.map((tool) => this.mcp.registerTool(
|
||||
tool.title,
|
||||
tool.config,
|
||||
tool.handler,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
public handleToolListChange(tools: ToolDef[], sendToolListChangedNotification: boolean = true): void {
|
||||
this.registerTools(...tools);
|
||||
if (sendToolListChangedNotification)
|
||||
this.mcp.sendToolListChanged();
|
||||
}
|
||||
|
||||
public async handleRequest(request: FastifyRequest, reply: FastifyReply) {
|
||||
reply.hijack();
|
||||
await this.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './ping';
|
||||
export * from './listTools';
|
||||
export * from './invokeTool';
|
||||
export * from './testAddRemoveTool';
|
||||
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
import { server } from "../server";
|
||||
import { ToolDef } from "../types";
|
||||
import { logged } from "../utils/logged";
|
||||
|
||||
export const invokeToolTool: ToolDef = {
|
||||
title: "invoke_tool",
|
||||
config: {
|
||||
description: "Invoke a local tool by name. Use list_tools first to see valid names and their argument schemas.",
|
||||
inputSchema: {
|
||||
name: z.string(),
|
||||
args: z.record(z.any()).default({}),
|
||||
},
|
||||
},
|
||||
handler: logged('invoke_tool', async ({ name, args }) => {
|
||||
const tool = server.tools[name];
|
||||
if (!tool)
|
||||
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
||||
return tool.handler(args); // dispatch to the real implementation
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import z from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { server } from "../server";
|
||||
import { ToolDef } from "../types";
|
||||
import { logged } from "../utils/logged";
|
||||
|
||||
export const listToolsTool: ToolDef = {
|
||||
title: "list_tools",
|
||||
config: {
|
||||
description: "Return the authoritative current list of available local tools with their schemas.",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('list_tools', async () => ({
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
Object.values(server.tools)
|
||||
.map((tool) => ({
|
||||
...tool,
|
||||
config: {
|
||||
...tool.config,
|
||||
inputSchema: zodToJsonSchema(z.object(tool.config.inputSchema), { target: "jsonSchema7" }),
|
||||
}
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}],
|
||||
})),
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import z from "zod";
|
||||
import { ToolDef } from "../types"
|
||||
import { isValidHost, logged } from "../utils/logged";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const pingTool: ToolDef = {
|
||||
title: "ping",
|
||||
config: {
|
||||
description: "ICMP-ping a host or IP address and return the result.",
|
||||
inputSchema: {
|
||||
host: z.string().describe("Hostname or IP address to ping"),
|
||||
count: z.number().int().min(1).max(10).default(4).describe("Echo requests to send"),
|
||||
},
|
||||
},
|
||||
handler: logged('ping', async ({ host, count }) => {
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Invalid host: ${host}` }],
|
||||
};
|
||||
}
|
||||
try {
|
||||
// -c count, -w deadline(s) so we never hang the request
|
||||
const { stdout } = await execAsync(`ping -r ${count} ${host}`, {
|
||||
timeout: 12_000,
|
||||
});
|
||||
return { content: [{ type: "text", text: stdout.trim() }] };
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null && "stdout" in err
|
||||
? String((err as { stdout?: unknown }).stdout)
|
||||
: String(err);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `ping failed:\n${message}` }],
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ToolDef } from "../types"
|
||||
import { logged } from "../utils/logged";
|
||||
import { server } from "../server";
|
||||
|
||||
const dynamicTool: ToolDef = {
|
||||
title: "say_hello",
|
||||
config: {
|
||||
description: "Says hello",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('say_hello', async () => {
|
||||
return { content: [{ type: "text", text: "hello" }] };
|
||||
}),
|
||||
}
|
||||
|
||||
export const testAddTool: ToolDef = {
|
||||
title: "test_add_tool",
|
||||
config: {
|
||||
description: "Dynamically add a new tool in runtime",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('test_add_tool', async () => {
|
||||
server.registerTools(dynamicTool);
|
||||
return { content: [{ type: "text", text: "New tool added" }] };
|
||||
}),
|
||||
};
|
||||
|
||||
export const testRemoveTool: ToolDef = {
|
||||
title: "test_remove_tool",
|
||||
config: {
|
||||
description: "Dynamically remove a tool in runtime",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('test_remove_tool', async () => {
|
||||
server.unregisterTools(dynamicTool.title);
|
||||
return { content: [{ type: "text", text: "Tool removed" }] };
|
||||
}),
|
||||
};
|
||||
+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);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ZodRawShape } from "zod";
|
||||
|
||||
export type TextResult = {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ToolDef = {
|
||||
title: string;
|
||||
config: {
|
||||
description: string;
|
||||
inputSchema: ZodRawShape,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (...args: any[]) => Promise<TextResult>;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { TextResult } from "../types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function logged<A extends any[]>(
|
||||
name: string,
|
||||
handler: (...args: A) => Promise<TextResult>,
|
||||
): (...args: A) => Promise<TextResult> {
|
||||
return async (...args: A) => {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const t0 = process.hrtime.bigint();
|
||||
console.log(`[tool>] ${name} #${id} args=${JSON.stringify(args)}`);
|
||||
try {
|
||||
const result = await handler(...args);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.log(
|
||||
`[tool<] ${name} #${id} ${ms.toFixed(1)}ms` +
|
||||
`${result.isError ? " ERROR" : ""} result=${JSON.stringify(result).slice(0, 2000)}`,
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.error(`[tool!] ${name} #${id} ${ms.toFixed(1)}ms threw:`, err);
|
||||
throw err; // let the SDK turn it into a JSON-RPC error
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidHost(host: string): boolean {
|
||||
// allow IPv4, IPv6, and hostnames; reject anything with shell metacharacters
|
||||
return /^[a-zA-Z0-9.:_-]+$/.test(host) && host.length <= 255;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user