@eznix/mcp-gateway 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +8 -0
- package/.github/workflows/docker.yml +51 -0
- package/.github/workflows/npm.yml +53 -0
- package/AGENTS.md +111 -0
- package/Dockerfile +22 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/index.js +26252 -0
- package/examples/README.md +34 -0
- package/examples/config.json +10 -0
- package/package.json +30 -0
- package/src/config.ts +63 -0
- package/src/connections.ts +116 -0
- package/src/docker.ts +64 -0
- package/src/gateway.ts +114 -0
- package/src/handlers.ts +113 -0
- package/src/index.ts +9 -0
- package/src/jobs.ts +74 -0
- package/src/search.ts +94 -0
- package/src/types.ts +51 -0
- package/templates/AGENTS.md +187 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Example Configurations
|
|
2
|
+
|
|
3
|
+
Copy `config.json` to `~/.config/mcp-gateway/config.json` and customize.
|
|
4
|
+
|
|
5
|
+
## Example Servers
|
|
6
|
+
|
|
7
|
+
| Server | Type | Description |
|
|
8
|
+
|--------|------|-------------|
|
|
9
|
+
| filesystem | remote | File system operations via HTTP |
|
|
10
|
+
| github | remote | GitHub API operations |
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Copy to your config location
|
|
16
|
+
cp config.json ~/.config/mcp-gateway/config.json
|
|
17
|
+
|
|
18
|
+
# Edit as needed
|
|
19
|
+
nano ~/.config/mcp-gateway/config.json
|
|
20
|
+
|
|
21
|
+
# Run gateway
|
|
22
|
+
bun run index.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Local Server Example
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"my-server": {
|
|
30
|
+
"type": "local",
|
|
31
|
+
"command": ["bun", "run", "/path/to/your/server.ts"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eznix/mcp-gateway",
|
|
3
|
+
"version": "1.3.3",
|
|
4
|
+
"description": "MCP Gateway - Aggregate multiple MCP servers into a single gateway",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-gateway": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "bun run src/index.ts",
|
|
11
|
+
"start:docker": "bun run src/docker.ts",
|
|
12
|
+
"build": "bun build src/index.ts --outdir dist --target node",
|
|
13
|
+
"build:docker": "bun build src/docker.ts --target bun --outfile=gateway",
|
|
14
|
+
"docker:build": "docker build -t mcp-gateway .",
|
|
15
|
+
"docker:run": "docker run -p 3000:3000 -v ./examples/config.json:/home/gateway/.config/mcp-gateway/config.json:ro mcp-gateway"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "latest",
|
|
19
|
+
"@types/node": "^25.0.9",
|
|
20
|
+
"typescript": "^5.9.3"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"typescript": "^5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
27
|
+
"lru-cache": "^11.2.4",
|
|
28
|
+
"minisearch": "^7.2.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, existsSync, watch, type FSWatcher } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
import type { GatewayConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), ".config", "mcp-gateway", "config.json");
|
|
8
|
+
|
|
9
|
+
export class Config {
|
|
10
|
+
private config: GatewayConfig;
|
|
11
|
+
private configPath: string;
|
|
12
|
+
private watcher?: FSWatcher;
|
|
13
|
+
|
|
14
|
+
constructor(path?: string) {
|
|
15
|
+
this.configPath = path || process.env.MCP_GATEWAY_CONFIG || DEFAULT_CONFIG_PATH;
|
|
16
|
+
this.config = this.load();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get<K extends keyof GatewayConfig>(key: K): GatewayConfig[K] | undefined {
|
|
20
|
+
return this.config[key];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getAll(): GatewayConfig {
|
|
24
|
+
return { ...this.config };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(key: string, value: any): void {
|
|
28
|
+
this.config[key] = value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getPath(): string {
|
|
32
|
+
return this.configPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reload(): GatewayConfig {
|
|
36
|
+
this.config = this.load();
|
|
37
|
+
return this.config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
watch(callback: (config: GatewayConfig) => void): void {
|
|
41
|
+
if (this.watcher) return;
|
|
42
|
+
this.watcher = watch(this.configPath, (event: string) => {
|
|
43
|
+
if (event !== "change") return;
|
|
44
|
+
this.reload();
|
|
45
|
+
callback(this.config);
|
|
46
|
+
});
|
|
47
|
+
console.error(` Watching config: ${this.configPath}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stopWatching(): void {
|
|
51
|
+
this.watcher?.close();
|
|
52
|
+
this.watcher = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private load(): GatewayConfig {
|
|
56
|
+
if (!existsSync(this.configPath)) return {};
|
|
57
|
+
return JSON.parse(readFileSync(this.configPath, "utf-8"));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getDefaultConfigPath(): string {
|
|
62
|
+
return process.env.MCP_GATEWAY_CONFIG || DEFAULT_CONFIG_PATH;
|
|
63
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
|
|
5
|
+
import type { UpstreamConfig, ToolCatalogEntry } from "./types.js";
|
|
6
|
+
import { SearchEngine } from "./search.js";
|
|
7
|
+
import { JobManager } from "./jobs.js";
|
|
8
|
+
|
|
9
|
+
export class ConnectionManager {
|
|
10
|
+
private upstreams = new Map<string, Client>();
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private searchEngine: SearchEngine,
|
|
14
|
+
private jobManager: JobManager,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async connect(serverKey: string, config: UpstreamConfig): Promise<void> {
|
|
18
|
+
if (config.type === "local") {
|
|
19
|
+
await this.connectLocal(serverKey, config);
|
|
20
|
+
} else {
|
|
21
|
+
await this.connectRemote(serverKey, config);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async connectLocal(serverKey: string, config: UpstreamConfig): Promise<void> {
|
|
26
|
+
const [cmd, ...args] = config.command || [];
|
|
27
|
+
if (!cmd) throw new Error(`Missing command for ${serverKey}`);
|
|
28
|
+
|
|
29
|
+
const transport = new StdioClientTransport({ command: cmd, args });
|
|
30
|
+
await this.connectTransport(serverKey, config, transport);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async connectRemote(serverKey: string, config: UpstreamConfig): Promise<void> {
|
|
34
|
+
const url = new URL(config.url || "");
|
|
35
|
+
const transportType = config.transport || (url.protocol === "ws:" || url.protocol === "wss:" ? "websocket" : "streamable_http");
|
|
36
|
+
const transport = transportType === "websocket" ? new WebSocketClientTransport(url) : new StreamableHTTPClientTransport(url);
|
|
37
|
+
await this.connectTransport(serverKey, config, transport);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async connectTransport(serverKey: string, config: UpstreamConfig, transport: any): Promise<void> {
|
|
41
|
+
transport.onclose = () => console.error(`[${serverKey}] Connection closed`);
|
|
42
|
+
transport.onerror = (error: Error) => {
|
|
43
|
+
// Suppress JSON parse errors from server logs
|
|
44
|
+
if (error.message.includes("JSON Parse error")) return;
|
|
45
|
+
console.error(`[${serverKey}] Connection error:`, error.message);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const client = new Client({ name: `gateway-${serverKey}`, version: "1.0.0" }, {});
|
|
49
|
+
await client.connect(transport);
|
|
50
|
+
this.upstreams.set(serverKey, client);
|
|
51
|
+
|
|
52
|
+
await this.refreshCatalog(serverKey, client);
|
|
53
|
+
console.error(`[${serverKey}] Connected with ${this.countTools(serverKey)} tools`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async refreshCatalog(serverKey: string, client: Client): Promise<void> {
|
|
57
|
+
const response = await client.listTools();
|
|
58
|
+
for (const tool of response.tools) {
|
|
59
|
+
const entry: ToolCatalogEntry = {
|
|
60
|
+
id: `${serverKey}::${tool.name}`,
|
|
61
|
+
server: serverKey,
|
|
62
|
+
name: tool.name,
|
|
63
|
+
description: tool.description,
|
|
64
|
+
inputSchema: tool.inputSchema,
|
|
65
|
+
};
|
|
66
|
+
this.searchEngine.addTool(entry);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private countTools(serverKey: string): number {
|
|
71
|
+
return this.searchEngine.getTools().filter((t) => t.server === serverKey).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async connectWithRetry(serverKey: string, config: UpstreamConfig, maxRetries = 5, baseDelay = 1000): Promise<void> {
|
|
75
|
+
let lastError: Error | undefined;
|
|
76
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
77
|
+
try {
|
|
78
|
+
return await this.connect(serverKey, config);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
lastError = error as Error;
|
|
81
|
+
if (i < maxRetries - 1) {
|
|
82
|
+
const delay = baseDelay * Math.pow(2, i);
|
|
83
|
+
console.error(`[${serverKey}] Connection failed (${i + 1}/${maxRetries}), retry in ${delay}ms`);
|
|
84
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getClient(serverKey: string): Client | undefined {
|
|
92
|
+
return this.upstreams.get(serverKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getAllClients(): Map<string, Client> {
|
|
96
|
+
return new Map(this.upstreams);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async disconnect(serverKey: string): Promise<void> {
|
|
100
|
+
const client = this.upstreams.get(serverKey);
|
|
101
|
+
if (client) {
|
|
102
|
+
await client.close();
|
|
103
|
+
this.upstreams.delete(serverKey);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async disconnectAll(): Promise<void> {
|
|
108
|
+
for (const [key] of this.upstreams) {
|
|
109
|
+
await this.disconnect(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getConnectedServers(): string[] {
|
|
114
|
+
return Array.from(this.upstreams.keys());
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/docker.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
4
|
+
import { MCPGateway } from "./gateway.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { createServer } from "http";
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const gateway = new MCPGateway();
|
|
10
|
+
const transport = await gateway.startWithHttp(3000);
|
|
11
|
+
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
if (req.url === "/") {
|
|
14
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
15
|
+
res.end(JSON.stringify({
|
|
16
|
+
name: "MCP Gateway",
|
|
17
|
+
description: "Aggregate multiple MCP servers into a single gateway",
|
|
18
|
+
version: packageJson.version,
|
|
19
|
+
endpoints: {
|
|
20
|
+
mcp: "/mcp",
|
|
21
|
+
health: "/health"
|
|
22
|
+
}
|
|
23
|
+
}, null, 2));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (req.url === "/health") {
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (req.url === "/mcp" && req.method === "POST") {
|
|
34
|
+
transport.handleRequest(req, res);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (req.url === "/mcp" && req.method === "GET") {
|
|
39
|
+
transport.handleRequest(req, res);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
res.writeHead(404);
|
|
44
|
+
res.end("Not found");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
server.listen(3000, () => {
|
|
48
|
+
console.error("HTTP server listening on http://localhost:3000");
|
|
49
|
+
console.error("MCP endpoint: http://localhost:3000/mcp");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
process.on("SIGINT", async () => {
|
|
53
|
+
server.close();
|
|
54
|
+
await gateway.shutdown();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
process.on("SIGTERM", async () => {
|
|
58
|
+
server.close();
|
|
59
|
+
await gateway.shutdown();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
|
|
5
|
+
import type { GatewayConfig } from "./types.js";
|
|
6
|
+
import { Config } from "./config.js";
|
|
7
|
+
import { SearchEngine } from "./search.js";
|
|
8
|
+
import { JobManager } from "./jobs.js";
|
|
9
|
+
import { ConnectionManager } from "./connections.js";
|
|
10
|
+
import { createServer } from "./handlers.js";
|
|
11
|
+
|
|
12
|
+
export class MCPGateway {
|
|
13
|
+
private config: Config;
|
|
14
|
+
private searchEngine: SearchEngine;
|
|
15
|
+
private jobManager: JobManager;
|
|
16
|
+
private connections: ConnectionManager;
|
|
17
|
+
private server: McpServer;
|
|
18
|
+
|
|
19
|
+
constructor(configPath?: string) {
|
|
20
|
+
this.config = new Config(configPath);
|
|
21
|
+
this.searchEngine = new SearchEngine();
|
|
22
|
+
this.jobManager = new JobManager();
|
|
23
|
+
this.connections = new ConnectionManager(this.searchEngine, this.jobManager);
|
|
24
|
+
this.server = createServer(this.searchEngine, this.connections, this.jobManager);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async connectAll(): Promise<void> {
|
|
28
|
+
const allConfig = this.config.getAll();
|
|
29
|
+
const connections = Object.entries(allConfig)
|
|
30
|
+
.filter(([_, c]) => c.enabled !== false)
|
|
31
|
+
.map(([k, c]) => this.connections.connectWithRetry(k, c));
|
|
32
|
+
const results = await Promise.allSettled(connections);
|
|
33
|
+
|
|
34
|
+
let success = 0, failed = 0;
|
|
35
|
+
for (const r of results) r.status === "fulfilled" ? success++ : failed++;
|
|
36
|
+
|
|
37
|
+
this.searchEngine.warmup();
|
|
38
|
+
console.error(`Connected: ${this.searchEngine.getTools().length} tools from ${success} servers (${failed} failed)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async startWithStdio(): Promise<void> {
|
|
42
|
+
console.error("MCP Gateway starting (stdio)...");
|
|
43
|
+
|
|
44
|
+
const transport = new StdioServerTransport();
|
|
45
|
+
await this.server.connect(transport);
|
|
46
|
+
|
|
47
|
+
console.log(`__MCP_GATEWAY_STDIO_READY__`);
|
|
48
|
+
|
|
49
|
+
this.connectAll().catch((err) => {
|
|
50
|
+
console.error(`Background connection error: ${err.message}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.config.watch((cfg) => this.handleConfigChange(cfg));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async startWithHttp(port: number = 3000): Promise<StreamableHTTPServerTransport> {
|
|
57
|
+
console.error(`MCP Gateway starting (http://localhost:${port})...`);
|
|
58
|
+
await this.connectAll();
|
|
59
|
+
|
|
60
|
+
const transport = new StreamableHTTPServerTransport({
|
|
61
|
+
sessionIdGenerator: undefined,
|
|
62
|
+
});
|
|
63
|
+
await this.server.connect(transport);
|
|
64
|
+
this.config.watch((cfg) => this.handleConfigChange(cfg));
|
|
65
|
+
|
|
66
|
+
return transport;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private handleConfigChange(newConfig: GatewayConfig): void {
|
|
70
|
+
const oldConfig = this.config.getAll();
|
|
71
|
+
const oldServers = new Set(Object.keys(oldConfig));
|
|
72
|
+
const newServers = new Set(Object.keys(newConfig));
|
|
73
|
+
|
|
74
|
+
const toRemove = [...oldServers].filter((s) => !newServers.has(s));
|
|
75
|
+
const toAdd = [...newServers].filter((s) => !oldServers.has(s));
|
|
76
|
+
const toUpdate = [...newServers].filter((s) => oldServers.has(s));
|
|
77
|
+
|
|
78
|
+
const doReload = async () => {
|
|
79
|
+
for (const key of toRemove) {
|
|
80
|
+
await this.connections.disconnect(key);
|
|
81
|
+
console.error(` ${key} disconnected`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const key of toUpdate) {
|
|
85
|
+
const oldC = oldConfig[key];
|
|
86
|
+
const newC = newConfig[key];
|
|
87
|
+
if (oldC && newC && oldC.enabled === false && newC.enabled !== false) {
|
|
88
|
+
try { await this.connections.connectWithRetry(key, newC); console.error(` ${key} connected`); } catch (e: any) { console.error(` ${key} failed: ${e.message}`); }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const key of toAdd) {
|
|
93
|
+
const c = newConfig[key];
|
|
94
|
+
if (c && c.enabled !== false) {
|
|
95
|
+
try { await this.connections.connectWithRetry(key, c); console.error(` ${key} connected`); } catch (e: any) { console.error(` ${key} failed: ${e.message}`); }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.searchEngine.warmup();
|
|
100
|
+
console.error(`Reloaded: ${this.searchEngine.getTools().length} tools from ${this.connections.getConnectedServers().length} servers`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Debounce reload
|
|
104
|
+
setTimeout(() => doReload(), 1000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async shutdown(): Promise<void> {
|
|
108
|
+
console.error("Shutting down gateway...");
|
|
109
|
+
this.config.stopWatching();
|
|
110
|
+
await this.jobManager.shutdown();
|
|
111
|
+
await this.connections.disconnectAll();
|
|
112
|
+
console.error("Gateway shutdown complete");
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import type { SearchFilters } from "./types.js";
|
|
5
|
+
import { SearchEngine } from "./search.js";
|
|
6
|
+
import { JobManager } from "./jobs.js";
|
|
7
|
+
import { ConnectionManager } from "./connections.js";
|
|
8
|
+
|
|
9
|
+
export function createServer(
|
|
10
|
+
searchEngine: SearchEngine,
|
|
11
|
+
connections: ConnectionManager,
|
|
12
|
+
jobManager: JobManager,
|
|
13
|
+
): McpServer {
|
|
14
|
+
const server = new McpServer({ name: "mcp-gateway", version: packageJson.version });
|
|
15
|
+
|
|
16
|
+
server.registerTool(
|
|
17
|
+
"gateway.search",
|
|
18
|
+
{
|
|
19
|
+
title: "Search Tools",
|
|
20
|
+
description: "Search for tools across all connected MCP servers with BM25 scoring and fuzzy matching",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
query: z.string(),
|
|
23
|
+
limit: z.number().optional(),
|
|
24
|
+
filters: z.object({ server: z.string() }).optional(),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
async ({ query, limit, filters }) => {
|
|
28
|
+
const results = searchEngine.search(query, (filters as SearchFilters) || {}, limit || 10);
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: JSON.stringify({ query, found: results.length, results }, null, 2) }],
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
server.registerTool(
|
|
36
|
+
"gateway.describe",
|
|
37
|
+
{
|
|
38
|
+
title: "Describe Tool",
|
|
39
|
+
description: "Get detailed information about a specific tool including full schema",
|
|
40
|
+
inputSchema: { id: z.string() },
|
|
41
|
+
},
|
|
42
|
+
async ({ id }) => {
|
|
43
|
+
const tool = searchEngine.getTool(id);
|
|
44
|
+
if (!tool) throw new Error(`TOOL_NOT_FOUND: ${id}`);
|
|
45
|
+
return { content: [{ type: "text", text: JSON.stringify(tool, null, 2) }] };
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
server.registerTool(
|
|
50
|
+
"gateway.invoke",
|
|
51
|
+
{
|
|
52
|
+
title: "Invoke Tool",
|
|
53
|
+
description: "Execute a tool synchronously and return the result",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
id: z.string(),
|
|
56
|
+
args: z.record(z.string(), z.unknown()),
|
|
57
|
+
timeoutMs: z.number().optional(),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async ({ id, args, timeoutMs }) => {
|
|
61
|
+
const parts = id.split("::");
|
|
62
|
+
const serverKey = parts[0];
|
|
63
|
+
const toolName = parts[1];
|
|
64
|
+
if (!serverKey || !toolName) throw new Error(`Invalid tool ID format: ${id}`);
|
|
65
|
+
|
|
66
|
+
const client = connections.getClient(serverKey);
|
|
67
|
+
if (!client) throw new Error(`SERVER_NOT_FOUND: ${serverKey}`);
|
|
68
|
+
const tool = searchEngine.getTool(id);
|
|
69
|
+
if (!tool) throw new Error(`TOOL_NOT_FOUND: ${id}`);
|
|
70
|
+
|
|
71
|
+
const result = await Promise.race([
|
|
72
|
+
client.callTool({ name: toolName, arguments: args }),
|
|
73
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs || 30000)),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
server.registerTool(
|
|
81
|
+
"gateway.invoke_async",
|
|
82
|
+
{
|
|
83
|
+
title: "Invoke Tool Async",
|
|
84
|
+
description: "Start an asynchronous tool execution and return a job ID for polling",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
id: z.string(),
|
|
87
|
+
args: z.record(z.string(), z.unknown()),
|
|
88
|
+
priority: z.number().optional(),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
async ({ id, args, priority }) => {
|
|
92
|
+
const job = jobManager.createJob(id, args, priority || 0);
|
|
93
|
+
jobManager.processQueue();
|
|
94
|
+
return { content: [{ type: "text", text: JSON.stringify({ jobId: job.id, status: "queued" }, null, 2) }] };
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
server.registerTool(
|
|
99
|
+
"gateway.invoke_status",
|
|
100
|
+
{
|
|
101
|
+
title: "Check Job Status",
|
|
102
|
+
description: "Check the status of an async job",
|
|
103
|
+
inputSchema: { jobId: z.string() },
|
|
104
|
+
},
|
|
105
|
+
async ({ jobId }) => {
|
|
106
|
+
const job = jobManager.getJob(jobId);
|
|
107
|
+
if (!job) throw new Error(`JOB_NOT_FOUND: ${jobId}`);
|
|
108
|
+
return { content: [{ type: "text", text: JSON.stringify(job, null, 2) }] };
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return server;
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { MCPGateway } from "./gateway.js";
|
|
4
|
+
|
|
5
|
+
const gateway = new MCPGateway(process.argv[2]);
|
|
6
|
+
gateway.startWithStdio().catch((err) => { console.error("Fatal error:", err); process.exit(1); });
|
|
7
|
+
|
|
8
|
+
process.on("SIGINT", () => gateway.shutdown().then(() => process.exit(0)));
|
|
9
|
+
process.on("SIGTERM", () => gateway.shutdown().then(() => process.exit(0)));
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LRUCache } from "lru-cache";
|
|
2
|
+
import type { JobRecord } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const MAX_CONCURRENT_JOBS = 3;
|
|
5
|
+
const SHUTDOWN_TIMEOUT = 30000;
|
|
6
|
+
const JOB_TTL = 1000 * 60 * 60 * 24;
|
|
7
|
+
|
|
8
|
+
export class JobManager {
|
|
9
|
+
private jobs = new LRUCache<string, JobRecord>({ max: 500, ttl: JOB_TTL });
|
|
10
|
+
private jobQueue: string[] = [];
|
|
11
|
+
private runningJobs = 0;
|
|
12
|
+
private executeJobFn: ((job: JobRecord) => Promise<void>) | null = null;
|
|
13
|
+
|
|
14
|
+
constructor() {}
|
|
15
|
+
|
|
16
|
+
setExecuteJob(fn: (job: JobRecord) => Promise<void>) {
|
|
17
|
+
this.executeJobFn = fn;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
createJob(toolId: string, args: any, priority = 0): JobRecord {
|
|
21
|
+
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
22
|
+
const job: JobRecord = {
|
|
23
|
+
id: jobId,
|
|
24
|
+
status: "queued",
|
|
25
|
+
toolId,
|
|
26
|
+
args,
|
|
27
|
+
priority,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
logs: [`Job created: ${toolId}`],
|
|
30
|
+
};
|
|
31
|
+
this.jobs.set(jobId, job);
|
|
32
|
+
this.jobQueue.push(jobId);
|
|
33
|
+
this.jobQueue.sort((a, b) => (this.jobs.get(b)?.priority || 0) - (this.jobs.get(a)?.priority || 0));
|
|
34
|
+
return job;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getJob(jobId: string): JobRecord | undefined {
|
|
38
|
+
return this.jobs.get(jobId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
processQueue() {
|
|
42
|
+
while (this.runningJobs < MAX_CONCURRENT_JOBS && this.jobQueue.length > 0) {
|
|
43
|
+
const jobId = this.jobQueue.shift()!;
|
|
44
|
+
const job = this.jobs.get(jobId);
|
|
45
|
+
if (!job || !this.executeJobFn) continue;
|
|
46
|
+
|
|
47
|
+
this.runningJobs++;
|
|
48
|
+
this.executeJobFn(job).finally(() => {
|
|
49
|
+
this.runningJobs--;
|
|
50
|
+
this.processQueue();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getRunningCount(): number {
|
|
56
|
+
return this.runningJobs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async shutdown(): Promise<void> {
|
|
60
|
+
this.jobQueue = [];
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
while (this.runningJobs > 0 && Date.now() - startTime < SHUTDOWN_TIMEOUT) {
|
|
63
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getStats(): { queued: number; running: number; total: number } {
|
|
68
|
+
return {
|
|
69
|
+
queued: this.jobQueue.length,
|
|
70
|
+
running: this.runningJobs,
|
|
71
|
+
total: this.jobs.size,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|