initial commit

This commit is contained in:
azykov@mail.ru
2026-07-01 10:34:30 +03:00
commit 5e234d9d61
16 changed files with 3032 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules
+10
View File
@@ -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/**"],
},
);
+40
View File
@@ -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"
}
}
+2528
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+6
View File
@@ -0,0 +1,6 @@
import { server } from './server';
server.start().catch((err) => {
console.error(err);
process.exit(1);
});
+72
View File
@@ -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);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './ping';
export * from './listTools';
export * from './invokeTool';
export * from './testAddRemoveTool';
+21
View File
@@ -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
}),
}
+30
View File
@@ -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,
)
}],
})),
}
+44
View File
@@ -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}` }],
};
}
}),
};
+38
View File
@@ -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
View File
@@ -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);
+19
View File
@@ -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>;
};
+32
View File
@@ -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;
}
+19
View File
@@ -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"]
}