@teampitch/mcpx 0.2.1 → 0.3.1
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/package.json +1 -1
- package/src/backends.test.ts +5 -2
- package/src/backends.ts +67 -20
- package/src/config.ts +7 -0
- package/src/executor.test.ts +3 -3
- package/src/executor.ts +125 -31
- package/src/index.ts +337 -265
- package/src/oauth-client.ts +337 -0
- package/src/skills.test.ts +166 -0
- package/src/skills.ts +153 -0
- package/src/stdio.ts +5 -6
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join, dirname } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -18,6 +19,14 @@ import {
|
|
|
18
19
|
import { loadConfig } from "./config.js";
|
|
19
20
|
import { executeCode } from "./executor.js";
|
|
20
21
|
import { createOAuthRoutes } from "./oauth.js";
|
|
22
|
+
import {
|
|
23
|
+
loadSkills,
|
|
24
|
+
registerSkill,
|
|
25
|
+
searchSkills,
|
|
26
|
+
recordExecution,
|
|
27
|
+
watchSkills,
|
|
28
|
+
generateSkillTypeDefs,
|
|
29
|
+
} from "./skills.js";
|
|
21
30
|
import { startStdioServer } from "./stdio.js";
|
|
22
31
|
import { watchConfig } from "./watcher.js";
|
|
23
32
|
|
|
@@ -45,320 +54,383 @@ if (command === "stdio") {
|
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
// HTTP server mode (default)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
if (command !== "stdio") {
|
|
58
|
+
const configPath = process.argv[2] ?? "mcpx.json";
|
|
59
|
+
|
|
60
|
+
let config;
|
|
61
|
+
try {
|
|
62
|
+
config = loadConfig(configPath);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg =
|
|
65
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
66
|
+
? `Config file not found: ${configPath}\n Create it or pass the path as an argument: mcpx <config.json>`
|
|
67
|
+
: `Failed to load config from ${configPath}: ${(err as Error).message}`;
|
|
68
|
+
console.error(`mcpx startup error: ${msg}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
61
71
|
|
|
62
|
-
console.log("mcpx starting...");
|
|
63
|
-
console.log(` version: ${VERSION}`);
|
|
64
|
-
console.log(` config: ${configPath}`);
|
|
65
|
-
console.log(` port: ${config.port}`);
|
|
66
|
-
console.log(` backends: ${Object.keys(config.backends).join(", ")}`);
|
|
67
|
-
|
|
68
|
-
// Connect to all backend MCP servers
|
|
69
|
-
console.log("\nConnecting to backends:");
|
|
70
|
-
let backends: Map<string, import("./backends.js").Backend>;
|
|
71
|
-
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
console.log("mcpx starting...");
|
|
73
|
+
console.log(` version: ${VERSION}`);
|
|
74
|
+
console.log(` config: ${configPath}`);
|
|
75
|
+
console.log(` port: ${config.port}`);
|
|
76
|
+
console.log(` backends: ${Object.keys(config.backends).join(", ")}`);
|
|
77
|
+
|
|
78
|
+
// Connect to all backend MCP servers
|
|
79
|
+
console.log("\nConnecting to backends:");
|
|
80
|
+
let backends: Map<string, import("./backends.js").Backend>;
|
|
81
|
+
try {
|
|
82
|
+
const tokensDir = join(configPath.replace(/[^/]+$/, ""), ".mcpx", "tokens");
|
|
83
|
+
backends = await connectBackends(config.backends, { tokensDir });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`Failed to connect backends: ${(err as Error).message}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
77
88
|
|
|
78
|
-
if (backends.size === 0 && !config.failOpen) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
89
|
+
if (backends.size === 0 && !config.failOpen) {
|
|
90
|
+
console.error(
|
|
91
|
+
"No backends connected. Check that your backend commands are installed and accessible.\n Use failOpen: true in config to start anyway.",
|
|
92
|
+
);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
84
95
|
|
|
85
|
-
if (backends.size === 0) {
|
|
86
|
-
|
|
87
|
-
}
|
|
96
|
+
if (backends.size === 0) {
|
|
97
|
+
console.warn("Warning: no backends connected (failOpen mode — server will start degraded)");
|
|
98
|
+
}
|
|
88
99
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.log(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
// Load skills from .mcpx/skills/
|
|
101
|
+
const skillsDir = join(configPath.replace(/[^/]+$/, ""), ".mcpx", "skills");
|
|
102
|
+
const skills = loadSkills(skillsDir);
|
|
103
|
+
if (skills.size > 0) console.log(` ${skills.size} skills loaded from ${skillsDir}`);
|
|
104
|
+
watchSkills(skillsDir, skills, () => {
|
|
105
|
+
console.log(`Skills reloaded (${skills.size} skills)`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Pre-generate type definitions and tool listing (mutable for hot-reload + tool refresh)
|
|
109
|
+
let typeDefs = generateTypeDefinitions(backends);
|
|
110
|
+
let skillTypeDefs = generateSkillTypeDefs(skills);
|
|
111
|
+
let toolListing = generateToolListing(backends);
|
|
112
|
+
|
|
113
|
+
let totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
|
|
114
|
+
console.log(`\n${totalTools} tools from ${backends.size} backends → 2 Code Mode tools`);
|
|
115
|
+
|
|
116
|
+
// Periodic tool refresh
|
|
117
|
+
if (config.toolRefreshInterval && config.toolRefreshInterval > 0) {
|
|
118
|
+
setInterval(async () => {
|
|
119
|
+
try {
|
|
120
|
+
await refreshAllTools(backends);
|
|
121
|
+
typeDefs = generateTypeDefinitions(backends);
|
|
122
|
+
toolListing = generateToolListing(backends);
|
|
123
|
+
totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error("Tool refresh failed:", (err as Error).message);
|
|
126
|
+
}
|
|
127
|
+
}, config.toolRefreshInterval * 1000);
|
|
128
|
+
}
|
|
109
129
|
|
|
110
|
-
// Hot-reload: watch config file for changes
|
|
111
|
-
watchConfig(configPath, backends, (newConfig, diff) => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// Create the MCP server with 2 Code Mode tools
|
|
122
|
-
function createMcpServer(visibleBackends: Map<string, Backend>): McpServer {
|
|
123
|
-
const server = new McpServer({
|
|
124
|
-
name: "mcpx",
|
|
125
|
-
version: VERSION,
|
|
130
|
+
// Hot-reload: watch config file for changes
|
|
131
|
+
watchConfig(configPath, backends, (newConfig, diff) => {
|
|
132
|
+
config = newConfig;
|
|
133
|
+
typeDefs = generateTypeDefinitions(backends);
|
|
134
|
+
toolListing = generateToolListing(backends);
|
|
135
|
+
totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
|
|
136
|
+
console.log(
|
|
137
|
+
`Config reloaded: +${diff.added.length} -${diff.removed.length} ~${diff.changed.length} (${totalTools} tools)`,
|
|
138
|
+
);
|
|
126
139
|
});
|
|
127
140
|
|
|
128
|
-
server
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
// Create the MCP server with Code Mode tools + skill management
|
|
142
|
+
function createMcpServer(visibleBackends: Map<string, Backend>): McpServer {
|
|
143
|
+
const server = new McpServer({
|
|
144
|
+
name: "mcpx",
|
|
145
|
+
version: VERSION,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.tool(
|
|
149
|
+
"search",
|
|
150
|
+
`Search available tools and skills. Returns type definitions for matched tools.
|
|
131
151
|
|
|
132
152
|
Available tools:
|
|
133
153
|
${toolListing}`,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
{
|
|
155
|
+
query: z
|
|
156
|
+
.string()
|
|
157
|
+
.describe("Search query — tool name, backend name, skill name, or keyword"),
|
|
158
|
+
},
|
|
159
|
+
async ({ query }) => {
|
|
160
|
+
const q = query.toLowerCase();
|
|
161
|
+
const matched: string[] = [];
|
|
162
|
+
|
|
163
|
+
for (const [name, backend] of visibleBackends) {
|
|
164
|
+
for (const tool of backend.tools) {
|
|
165
|
+
const fullName = `${name}_${tool.name}`;
|
|
166
|
+
const desc = tool.description?.toLowerCase() ?? "";
|
|
167
|
+
if (
|
|
168
|
+
fullName.toLowerCase().includes(q) ||
|
|
169
|
+
desc.includes(q) ||
|
|
170
|
+
name.toLowerCase().includes(q)
|
|
171
|
+
) {
|
|
172
|
+
const params = tool.inputSchema?.properties
|
|
173
|
+
? JSON.stringify(tool.inputSchema.properties, null, 2)
|
|
174
|
+
: "{}";
|
|
175
|
+
matched.push(`### ${fullName}\n${tool.description ?? ""}\nParameters: ${params}`);
|
|
176
|
+
}
|
|
154
177
|
}
|
|
155
178
|
}
|
|
156
|
-
}
|
|
157
179
|
|
|
158
|
-
|
|
180
|
+
// Also search skills
|
|
181
|
+
const matchedSkills = searchSkills(skills, query);
|
|
182
|
+
for (const s of matchedSkills) {
|
|
183
|
+
matched.push(
|
|
184
|
+
`### skill.${s.name} [${s.trust}]\n${s.description}\nCode: ${s.code.slice(0, 200)}${s.code.length > 200 ? "..." : ""}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (matched.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text" as const,
|
|
193
|
+
text: `No tools or skills found matching "${query}".`,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
159
199
|
return {
|
|
160
200
|
content: [
|
|
161
201
|
{
|
|
162
202
|
type: "text" as const,
|
|
163
|
-
text: `
|
|
203
|
+
text: `Found ${matched.length} results:\n\n${matched.join("\n\n")}`,
|
|
164
204
|
},
|
|
165
205
|
],
|
|
166
206
|
};
|
|
167
|
-
}
|
|
207
|
+
},
|
|
208
|
+
);
|
|
168
209
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
type: "text" as const,
|
|
173
|
-
text: `Found ${matched.length} tools:\n\n${matched.join("\n\n")}`,
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
};
|
|
177
|
-
},
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
server.tool(
|
|
181
|
-
"execute",
|
|
182
|
-
`Execute JavaScript code that calls MCP tools. The code runs in a V8 isolate.
|
|
210
|
+
server.tool(
|
|
211
|
+
"execute",
|
|
212
|
+
`Execute JavaScript code that calls MCP tools. The code runs in a V8 isolate.
|
|
183
213
|
|
|
184
214
|
Write an async function body. Available tool functions (call with await):
|
|
185
215
|
${typeDefs}
|
|
216
|
+
${skillTypeDefs}
|
|
186
217
|
|
|
187
|
-
Example
|
|
218
|
+
Example:
|
|
188
219
|
const result = await grafana.searchDashboards({ query: "pods" });
|
|
189
|
-
return result;
|
|
190
|
-
|
|
191
|
-
Example (classic style):
|
|
192
|
-
const result = await grafana_search_dashboards({ query: "pods" });
|
|
193
220
|
return result;`,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
msg
|
|
221
|
+
{
|
|
222
|
+
code: z.string().describe("JavaScript async function body to execute"),
|
|
223
|
+
},
|
|
224
|
+
async ({ code }) => {
|
|
225
|
+
const result = await executeCode(code, visibleBackends, { skills });
|
|
226
|
+
|
|
227
|
+
if (result.isErr()) {
|
|
228
|
+
const e = result.error;
|
|
229
|
+
let msg = e.kind === "runtime" ? `Execution failed with code ${e.code}` : e.message;
|
|
230
|
+
if (e.kind === "parse" && e.snippet) msg += `\n\n${e.snippet}`;
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
|
233
|
+
isError: true,
|
|
234
|
+
};
|
|
203
235
|
}
|
|
236
|
+
|
|
237
|
+
const val = result.value.value;
|
|
238
|
+
const text = typeof val === "string" ? val : JSON.stringify(val, null, 2);
|
|
239
|
+
const logText =
|
|
240
|
+
result.value.logs.length > 0
|
|
241
|
+
? `\n\n--- Console Output ---\n${result.value.logs.map((l) => `[${l.level}] ${l.args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}`).join("\n")}`
|
|
242
|
+
: "";
|
|
243
|
+
const eventText =
|
|
244
|
+
result.value.events.filter((e) => e.type === "tool_call" || e.type === "tool_error")
|
|
245
|
+
.length > 0
|
|
246
|
+
? `\n\n--- Execution Events ---\n${result.value.events
|
|
247
|
+
.filter((e) => e.type !== "console")
|
|
248
|
+
.map(
|
|
249
|
+
(e) =>
|
|
250
|
+
`[${e.type}] ${e.tool ?? ""}${e.durationMs ? ` (${e.durationMs}ms)` : ""}${e.error ? ` ERROR: ${e.error}` : ""}`,
|
|
251
|
+
)
|
|
252
|
+
.join("\n")}`
|
|
253
|
+
: "";
|
|
254
|
+
|
|
204
255
|
return {
|
|
205
|
-
content: [{ type: "text" as const, text:
|
|
206
|
-
isError: true,
|
|
256
|
+
content: [{ type: "text" as const, text: text + logText + eventText }],
|
|
207
257
|
};
|
|
208
|
-
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
server.tool(
|
|
262
|
+
"register_skill",
|
|
263
|
+
"Save working code as a reusable skill. The skill becomes available to all agents connected to this gateway.",
|
|
264
|
+
{
|
|
265
|
+
name: z.string().describe("Skill name (alphanumeric + hyphens)"),
|
|
266
|
+
description: z.string().describe("What this skill does"),
|
|
267
|
+
code: z.string().describe("JavaScript async function body (same as execute code)"),
|
|
268
|
+
},
|
|
269
|
+
async ({ name, description, code: skillCode }) => {
|
|
270
|
+
const skill = registerSkill(skillsDir, skills, {
|
|
271
|
+
name,
|
|
272
|
+
description,
|
|
273
|
+
code: skillCode,
|
|
274
|
+
});
|
|
275
|
+
skillTypeDefs = generateSkillTypeDefs(skills);
|
|
276
|
+
return {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text" as const,
|
|
280
|
+
text: `Skill "${skill.name}" registered (${skill.trust}). Available as skill.${skill.name}() in execute.`,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
);
|
|
209
286
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const logText =
|
|
213
|
-
result.value.logs.length > 0
|
|
214
|
-
? `\n\n--- Console Output ---\n${result.value.logs.map((l) => `[${l.level}] ${l.args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}`).join("\n")}`
|
|
215
|
-
: "";
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
content: [
|
|
219
|
-
{
|
|
220
|
-
type: "text" as const,
|
|
221
|
-
text: text + logText,
|
|
222
|
-
},
|
|
223
|
-
],
|
|
224
|
-
};
|
|
225
|
-
},
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
return server;
|
|
229
|
-
}
|
|
287
|
+
return server;
|
|
288
|
+
}
|
|
230
289
|
|
|
231
|
-
// HTTP server with Hono
|
|
232
|
-
const app = new Hono();
|
|
233
|
-
|
|
234
|
-
// Record start time for uptime reporting
|
|
235
|
-
const startedAt = Date.now();
|
|
236
|
-
|
|
237
|
-
// Health check — includes uptime, version, and per-backend tool counts
|
|
238
|
-
app.get("/health", (c) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
290
|
+
// HTTP server with Hono
|
|
291
|
+
const app = new Hono();
|
|
292
|
+
|
|
293
|
+
// Record start time for uptime reporting
|
|
294
|
+
const startedAt = Date.now();
|
|
295
|
+
|
|
296
|
+
// Health check — includes uptime, version, and per-backend tool counts
|
|
297
|
+
app.get("/health", (c) => {
|
|
298
|
+
const backendDetails = Array.from(backends.entries()).map(([name, backend]) => ({
|
|
299
|
+
name,
|
|
300
|
+
tools: backend.tools.length,
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
return c.json({
|
|
304
|
+
status: backends.size === 0 ? "degraded" : "ok",
|
|
305
|
+
version: VERSION,
|
|
306
|
+
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
|
|
307
|
+
backends: backendDetails,
|
|
308
|
+
totalTools,
|
|
309
|
+
});
|
|
250
310
|
});
|
|
251
|
-
});
|
|
252
311
|
|
|
253
|
-
// Mount OAuth routes if configured
|
|
254
|
-
if (config.auth?.oauth) {
|
|
255
|
-
|
|
256
|
-
}
|
|
312
|
+
// Mount OAuth routes if configured
|
|
313
|
+
if (config.auth?.oauth) {
|
|
314
|
+
createOAuthRoutes(config.auth.oauth, app);
|
|
315
|
+
}
|
|
257
316
|
|
|
258
|
-
// Auth middleware — JWT, bearer, OAuth, or open
|
|
259
|
-
const verifier = createAuthVerifier(config);
|
|
260
|
-
if (verifier) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
317
|
+
// Auth middleware — JWT, bearer, OAuth, or open
|
|
318
|
+
const verifier = createAuthVerifier(config);
|
|
319
|
+
if (verifier) {
|
|
320
|
+
app.use("/mcp", async (c, next) => {
|
|
321
|
+
const authHeader = c.req.header("Authorization");
|
|
322
|
+
const token = authHeader?.replace(/^Bearer\s+/i, "");
|
|
323
|
+
if (!token) {
|
|
324
|
+
// Per MCP OAuth spec — include metadata URL in 401 response
|
|
325
|
+
const headers: Record<string, string> = {};
|
|
326
|
+
if (config.auth?.oauth) {
|
|
327
|
+
headers["WWW-Authenticate"] =
|
|
328
|
+
`Bearer resource_metadata="/.well-known/oauth-authorization-server"`;
|
|
329
|
+
}
|
|
330
|
+
return c.json({ error: "unauthorized" }, { status: 401, headers });
|
|
270
331
|
}
|
|
271
|
-
|
|
332
|
+
|
|
333
|
+
const result = await verifier(token);
|
|
334
|
+
if (result.isErr()) return c.json({ error: result.error }, 401);
|
|
335
|
+
|
|
336
|
+
// Store claims for per-backend filtering
|
|
337
|
+
c.set("claims" as never, result.value as never);
|
|
338
|
+
await next();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Session management for stateful MCP connections
|
|
343
|
+
const sessions = new Map<
|
|
344
|
+
string,
|
|
345
|
+
{
|
|
346
|
+
server: McpServer;
|
|
347
|
+
transport: WebStandardStreamableHTTPServerTransport;
|
|
348
|
+
lastAccess: number;
|
|
349
|
+
}
|
|
350
|
+
>();
|
|
351
|
+
const sessionTtlMs = (config.sessionTtlMinutes ?? 30) * 60 * 1000;
|
|
352
|
+
|
|
353
|
+
// Expire stale sessions every minute
|
|
354
|
+
setInterval(() => {
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
for (const [id, session] of sessions) {
|
|
357
|
+
if (now - session.lastAccess > sessionTtlMs) {
|
|
358
|
+
sessions.delete(id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, 60_000);
|
|
362
|
+
|
|
363
|
+
// MCP endpoint — Streamable HTTP with session support
|
|
364
|
+
app.all("/mcp", async (c) => {
|
|
365
|
+
// Resolve visible backends based on auth claims
|
|
366
|
+
const claims = c.get("claims" as never) as AuthClaims | undefined;
|
|
367
|
+
const visibleBackends = claims
|
|
368
|
+
? filterBackendsByClaims(backends, claims, config.backends)
|
|
369
|
+
: backends;
|
|
370
|
+
|
|
371
|
+
const sessionId = c.req.header("mcp-session-id");
|
|
372
|
+
|
|
373
|
+
// Reuse existing session
|
|
374
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
375
|
+
const session = sessions.get(sessionId)!;
|
|
376
|
+
session.lastAccess = Date.now();
|
|
377
|
+
const response = await session.transport.handleRequest(c.req.raw);
|
|
378
|
+
return response;
|
|
272
379
|
}
|
|
273
380
|
|
|
274
|
-
|
|
275
|
-
|
|
381
|
+
// New session
|
|
382
|
+
const server = createMcpServer(visibleBackends);
|
|
383
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
384
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
385
|
+
});
|
|
276
386
|
|
|
277
|
-
|
|
278
|
-
c.set("claims" as never, result.value as never);
|
|
279
|
-
await next();
|
|
280
|
-
});
|
|
281
|
-
}
|
|
387
|
+
await server.connect(transport);
|
|
282
388
|
|
|
283
|
-
//
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
lastAccess: number;
|
|
290
|
-
}
|
|
291
|
-
>();
|
|
292
|
-
const sessionTtlMs = (config.sessionTtlMinutes ?? 30) * 60 * 1000;
|
|
293
|
-
|
|
294
|
-
// Expire stale sessions every minute
|
|
295
|
-
setInterval(() => {
|
|
296
|
-
const now = Date.now();
|
|
297
|
-
for (const [id, session] of sessions) {
|
|
298
|
-
if (now - session.lastAccess > sessionTtlMs) {
|
|
299
|
-
sessions.delete(id);
|
|
389
|
+
// Store session after first response (which contains the session ID)
|
|
390
|
+
const response = await transport.handleRequest(c.req.raw);
|
|
391
|
+
|
|
392
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
393
|
+
if (newSessionId) {
|
|
394
|
+
sessions.set(newSessionId, { server, transport, lastAccess: Date.now() });
|
|
300
395
|
}
|
|
301
|
-
}
|
|
302
|
-
}, 60_000);
|
|
303
|
-
|
|
304
|
-
// MCP endpoint — Streamable HTTP with session support
|
|
305
|
-
app.all("/mcp", async (c) => {
|
|
306
|
-
// Resolve visible backends based on auth claims
|
|
307
|
-
const claims = c.get("claims" as never) as AuthClaims | undefined;
|
|
308
|
-
const visibleBackends = claims
|
|
309
|
-
? filterBackendsByClaims(backends, claims, config.backends)
|
|
310
|
-
: backends;
|
|
311
|
-
|
|
312
|
-
const sessionId = c.req.header("mcp-session-id");
|
|
313
|
-
|
|
314
|
-
// Reuse existing session
|
|
315
|
-
if (sessionId && sessions.has(sessionId)) {
|
|
316
|
-
const session = sessions.get(sessionId)!;
|
|
317
|
-
session.lastAccess = Date.now();
|
|
318
|
-
const response = await session.transport.handleRequest(c.req.raw);
|
|
319
|
-
return response;
|
|
320
|
-
}
|
|
321
396
|
|
|
322
|
-
|
|
323
|
-
const server = createMcpServer(visibleBackends);
|
|
324
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
325
|
-
sessionIdGenerator: () => crypto.randomUUID(),
|
|
397
|
+
return response;
|
|
326
398
|
});
|
|
327
399
|
|
|
328
|
-
|
|
400
|
+
// Graceful shutdown — disconnect all backend clients before exiting
|
|
401
|
+
async function shutdown(signal: string): Promise<void> {
|
|
402
|
+
console.log(`\nmcpx received ${signal}, shutting down...`);
|
|
403
|
+
const disconnects = Array.from(backends.values()).map((b) =>
|
|
404
|
+
b.client.close().catch((err: Error) => {
|
|
405
|
+
console.error(` failed to disconnect backend ${b.name}: ${err.message}`);
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
await Promise.allSettled(disconnects);
|
|
409
|
+
console.log("mcpx shutdown complete");
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
329
412
|
|
|
330
|
-
|
|
331
|
-
|
|
413
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
414
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
332
415
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
416
|
+
console.log(`\nmcpx listening on http://localhost:${config.port}`);
|
|
417
|
+
console.log(` MCP endpoint: http://localhost:${config.port}/mcp`);
|
|
418
|
+
console.log(` Health: http://localhost:${config.port}/health`);
|
|
337
419
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
async function shutdown(signal: string): Promise<void> {
|
|
343
|
-
console.log(`\nmcpx received ${signal}, shutting down...`);
|
|
344
|
-
const disconnects = Array.from(backends.values()).map((b) =>
|
|
345
|
-
b.client.close().catch((err: Error) => {
|
|
346
|
-
console.error(` failed to disconnect backend ${b.name}: ${err.message}`);
|
|
347
|
-
}),
|
|
348
|
-
);
|
|
349
|
-
await Promise.allSettled(disconnects);
|
|
350
|
-
console.log("mcpx shutdown complete");
|
|
351
|
-
process.exit(0);
|
|
420
|
+
// Bun.serve compat — export default must be at module level but
|
|
421
|
+
// config/app are block-scoped, so we assign to module-level vars.
|
|
422
|
+
_exportPort = config.port;
|
|
423
|
+
_exportFetch = app.fetch;
|
|
352
424
|
}
|
|
353
425
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
console.log(`\nmcpx listening on http://localhost:${config.port}`);
|
|
358
|
-
console.log(` MCP endpoint: http://localhost:${config.port}/mcp`);
|
|
359
|
-
console.log(` Health: http://localhost:${config.port}/health`);
|
|
426
|
+
let _exportPort = 0;
|
|
427
|
+
let _exportFetch: (req: Request) => Response | Promise<Response> = () => new Response("stdio mode");
|
|
360
428
|
|
|
361
429
|
export default {
|
|
362
|
-
port
|
|
363
|
-
|
|
430
|
+
get port() {
|
|
431
|
+
return _exportPort;
|
|
432
|
+
},
|
|
433
|
+
get fetch() {
|
|
434
|
+
return _exportFetch;
|
|
435
|
+
},
|
|
364
436
|
};
|