@teampitch/mcpx 0.2.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/src/index.ts ADDED
@@ -0,0 +1,364 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7
+ import { Hono } from "hono";
8
+ import { z } from "zod";
9
+
10
+ import { createAuthVerifier, filterBackendsByClaims, type AuthClaims } from "./auth.js";
11
+ import {
12
+ connectBackends,
13
+ generateTypeDefinitions,
14
+ generateToolListing,
15
+ refreshAllTools,
16
+ type Backend,
17
+ } from "./backends.js";
18
+ import { loadConfig } from "./config.js";
19
+ import { executeCode } from "./executor.js";
20
+ import { createOAuthRoutes } from "./oauth.js";
21
+ import { startStdioServer } from "./stdio.js";
22
+ import { watchConfig } from "./watcher.js";
23
+
24
+ // Resolve version from package.json at startup
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")) as {
27
+ version: string;
28
+ };
29
+ const VERSION = pkg.version;
30
+
31
+ const command = process.argv[2];
32
+
33
+ // mcpx init [backend...]
34
+ if (command === "init") {
35
+ const { runInit } = await import("./init.js");
36
+ runInit(process.argv.slice(3));
37
+ process.exit(0);
38
+ }
39
+
40
+ // mcpx stdio mcpx.json
41
+ if (command === "stdio") {
42
+ const configPath = process.argv[3] ?? "mcpx.json";
43
+ await startStdioServer(configPath);
44
+ // Intentional: no process.exit() — StdioServerTransport keeps the event loop alive.
45
+ }
46
+
47
+ // HTTP server mode (default)
48
+ const configPath = process.argv[2] ?? "mcpx.json";
49
+
50
+ let config;
51
+ try {
52
+ config = loadConfig(configPath);
53
+ } catch (err) {
54
+ const msg =
55
+ (err as NodeJS.ErrnoException).code === "ENOENT"
56
+ ? `Config file not found: ${configPath}\n Create it or pass the path as an argument: mcpx <config.json>`
57
+ : `Failed to load config from ${configPath}: ${(err as Error).message}`;
58
+ console.error(`mcpx startup error: ${msg}`);
59
+ process.exit(1);
60
+ }
61
+
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
+ backends = await connectBackends(config.backends);
73
+ } catch (err) {
74
+ console.error(`Failed to connect backends: ${(err as Error).message}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ if (backends.size === 0 && !config.failOpen) {
79
+ console.error(
80
+ "No backends connected. Check that your backend commands are installed and accessible.\n Use failOpen: true in config to start anyway.",
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ if (backends.size === 0) {
86
+ console.warn("Warning: no backends connected (failOpen mode — server will start degraded)");
87
+ }
88
+
89
+ // Pre-generate type definitions and tool listing (mutable for hot-reload + tool refresh)
90
+ let typeDefs = generateTypeDefinitions(backends);
91
+ let toolListing = generateToolListing(backends);
92
+
93
+ let totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
94
+ console.log(`\n${totalTools} tools from ${backends.size} backends → 2 Code Mode tools`);
95
+
96
+ // Periodic tool refresh
97
+ if (config.toolRefreshInterval && config.toolRefreshInterval > 0) {
98
+ setInterval(async () => {
99
+ try {
100
+ await refreshAllTools(backends);
101
+ typeDefs = generateTypeDefinitions(backends);
102
+ toolListing = generateToolListing(backends);
103
+ totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
104
+ } catch (err) {
105
+ console.error("Tool refresh failed:", (err as Error).message);
106
+ }
107
+ }, config.toolRefreshInterval * 1000);
108
+ }
109
+
110
+ // Hot-reload: watch config file for changes
111
+ watchConfig(configPath, backends, (newConfig, diff) => {
112
+ config = newConfig;
113
+ typeDefs = generateTypeDefinitions(backends);
114
+ toolListing = generateToolListing(backends);
115
+ totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
116
+ console.log(
117
+ `Config reloaded: +${diff.added.length} -${diff.removed.length} ~${diff.changed.length} (${totalTools} tools)`,
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,
126
+ });
127
+
128
+ server.tool(
129
+ "search",
130
+ `Search available tools across all connected MCP servers. Returns type definitions for matched tools.
131
+
132
+ Available tools:
133
+ ${toolListing}`,
134
+ {
135
+ query: z.string().describe("Search query — tool name, backend name, or keyword"),
136
+ },
137
+ async ({ query }) => {
138
+ const q = query.toLowerCase();
139
+ const matched: string[] = [];
140
+
141
+ for (const [name, backend] of visibleBackends) {
142
+ for (const tool of backend.tools) {
143
+ const fullName = `${name}_${tool.name}`;
144
+ const desc = tool.description?.toLowerCase() ?? "";
145
+ if (
146
+ fullName.toLowerCase().includes(q) ||
147
+ desc.includes(q) ||
148
+ name.toLowerCase().includes(q)
149
+ ) {
150
+ const params = tool.inputSchema?.properties
151
+ ? JSON.stringify(tool.inputSchema.properties, null, 2)
152
+ : "{}";
153
+ matched.push(`### ${fullName}\n${tool.description ?? ""}\nParameters: ${params}`);
154
+ }
155
+ }
156
+ }
157
+
158
+ if (matched.length === 0) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text" as const,
163
+ text: `No tools found matching "${query}". Available backends: ${Array.from(visibleBackends.keys()).join(", ")}`,
164
+ },
165
+ ],
166
+ };
167
+ }
168
+
169
+ return {
170
+ content: [
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.
183
+
184
+ Write an async function body. Available tool functions (call with await):
185
+ ${typeDefs}
186
+
187
+ Example (namespace style):
188
+ 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
+ return result;`,
194
+ { code: z.string().describe("JavaScript async function body to execute") },
195
+ async ({ code }) => {
196
+ const result = await executeCode(code, visibleBackends);
197
+
198
+ if (result.isErr()) {
199
+ const e = result.error;
200
+ let msg = e.kind === "runtime" ? `Execution failed with code ${e.code}` : e.message;
201
+ if (e.kind === "parse" && e.snippet) {
202
+ msg += `\n\n${e.snippet}`;
203
+ }
204
+ return {
205
+ content: [{ type: "text" as const, text: `Error: ${msg}` }],
206
+ isError: true,
207
+ };
208
+ }
209
+
210
+ const val = result.value.value;
211
+ const text = typeof val === "string" ? val : JSON.stringify(val, null, 2);
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
+ }
230
+
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
+ const backendDetails = Array.from(backends.entries()).map(([name, backend]) => ({
240
+ name,
241
+ tools: backend.tools.length,
242
+ }));
243
+
244
+ return c.json({
245
+ status: backends.size === 0 ? "degraded" : "ok",
246
+ version: VERSION,
247
+ uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
248
+ backends: backendDetails,
249
+ totalTools,
250
+ });
251
+ });
252
+
253
+ // Mount OAuth routes if configured
254
+ if (config.auth?.oauth) {
255
+ createOAuthRoutes(config.auth.oauth, app);
256
+ }
257
+
258
+ // Auth middleware — JWT, bearer, OAuth, or open
259
+ const verifier = createAuthVerifier(config);
260
+ if (verifier) {
261
+ app.use("/mcp", async (c, next) => {
262
+ const authHeader = c.req.header("Authorization");
263
+ const token = authHeader?.replace(/^Bearer\s+/i, "");
264
+ if (!token) {
265
+ // Per MCP OAuth spec — include metadata URL in 401 response
266
+ const headers: Record<string, string> = {};
267
+ if (config.auth?.oauth) {
268
+ headers["WWW-Authenticate"] =
269
+ `Bearer resource_metadata="/.well-known/oauth-authorization-server"`;
270
+ }
271
+ return c.json({ error: "unauthorized" }, { status: 401, headers });
272
+ }
273
+
274
+ const result = await verifier(token);
275
+ if (result.isErr()) return c.json({ error: result.error }, 401);
276
+
277
+ // Store claims for per-backend filtering
278
+ c.set("claims" as never, result.value as never);
279
+ await next();
280
+ });
281
+ }
282
+
283
+ // Session management for stateful MCP connections
284
+ const sessions = new Map<
285
+ string,
286
+ {
287
+ server: McpServer;
288
+ transport: WebStandardStreamableHTTPServerTransport;
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);
300
+ }
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
+
322
+ // New session
323
+ const server = createMcpServer(visibleBackends);
324
+ const transport = new WebStandardStreamableHTTPServerTransport({
325
+ sessionIdGenerator: () => crypto.randomUUID(),
326
+ });
327
+
328
+ await server.connect(transport);
329
+
330
+ // Store session after first response (which contains the session ID)
331
+ const response = await transport.handleRequest(c.req.raw);
332
+
333
+ const newSessionId = response.headers.get("mcp-session-id");
334
+ if (newSessionId) {
335
+ sessions.set(newSessionId, { server, transport, lastAccess: Date.now() });
336
+ }
337
+
338
+ return response;
339
+ });
340
+
341
+ // Graceful shutdown — disconnect all backend clients before exiting
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);
352
+ }
353
+
354
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
355
+ process.on("SIGINT", () => shutdown("SIGINT"));
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`);
360
+
361
+ export default {
362
+ port: config.port,
363
+ fetch: app.fetch,
364
+ };
package/src/init.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ interface McpJsonServer {
6
+ command?: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ type?: string;
10
+ url?: string;
11
+ }
12
+
13
+ /** Find and parse existing .mcp.json files from Claude Code */
14
+ function findExistingMcpConfigs(): Record<string, McpJsonServer> {
15
+ const locations = [resolve(".mcp.json"), join(homedir(), ".claude.json")];
16
+
17
+ const servers: Record<string, McpJsonServer> = {};
18
+
19
+ for (const path of locations) {
20
+ if (!existsSync(path)) continue;
21
+ try {
22
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
23
+ const mcpServers = raw.mcpServers ?? {};
24
+ for (const [name, config] of Object.entries(mcpServers)) {
25
+ servers[name] = config as McpJsonServer;
26
+ }
27
+ console.log(` Found ${Object.keys(mcpServers).length} MCP servers in ${path}`);
28
+ } catch {
29
+ // skip unparseable files
30
+ }
31
+ }
32
+
33
+ return servers;
34
+ }
35
+
36
+ /** Convert a Claude Code MCP server config to an mcpx backend config */
37
+ function convertToBackend(server: McpJsonServer): object | null {
38
+ if (server.type === "http" || server.url) {
39
+ return { transport: "http", url: server.url };
40
+ }
41
+
42
+ if (server.command) {
43
+ return {
44
+ transport: "stdio",
45
+ command: server.command,
46
+ args: server.args ?? [],
47
+ ...(server.env ? { env: server.env } : {}),
48
+ };
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ export function runInit(args?: string[]) {
55
+ const configPath = resolve("mcpx.json");
56
+
57
+ if (existsSync(configPath)) {
58
+ console.error("mcpx.json already exists. Delete it first to re-initialize.");
59
+ process.exit(1);
60
+ }
61
+
62
+ const isEmpty = args?.[0] === "--empty";
63
+ let backends: Record<string, object> = {};
64
+
65
+ if (isEmpty) {
66
+ // Empty config — user fills in manually
67
+ backends = {
68
+ "my-server": {
69
+ transport: "stdio",
70
+ command: "npx",
71
+ args: ["-y", "your-mcp-server"],
72
+ env: { API_KEY: "${YOUR_API_KEY}" },
73
+ },
74
+ };
75
+ } else {
76
+ // Default: scan for existing MCP configs
77
+ console.log("Scanning for existing MCP server configs...");
78
+ const existing = findExistingMcpConfigs();
79
+
80
+ if (Object.keys(existing).length === 0) {
81
+ console.log(" No MCP servers found in .mcp.json or ~/.claude.json");
82
+ console.log(" Creating empty config — edit mcpx.json to add your backends.\n");
83
+ backends = {
84
+ "my-server": {
85
+ transport: "stdio",
86
+ command: "npx",
87
+ args: ["-y", "your-mcp-server"],
88
+ env: { API_KEY: "${YOUR_API_KEY}" },
89
+ },
90
+ };
91
+ } else {
92
+ for (const [name, server] of Object.entries(existing)) {
93
+ const backend = convertToBackend(server);
94
+ if (backend) {
95
+ backends[name] = backend;
96
+ console.log(` Imported: ${name}`);
97
+ } else {
98
+ console.log(` Skipped: ${name} (unsupported config)`);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ const config = { port: 3100, backends };
105
+
106
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
107
+
108
+ const count = Object.keys(backends).length;
109
+ console.log(`\nCreated mcpx.json with ${count} backend${count !== 1 ? "s" : ""}`);
110
+ console.log();
111
+ console.log("Next steps:");
112
+ console.log(" 1. Edit mcpx.json — add or configure your MCP backends");
113
+ console.log(" 2. Set environment variables referenced in the config");
114
+ console.log(" 3. Run: bunx mcpx-tools stdio mcpx.json");
115
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { crdToConfig } from "./k8s-controller.js";
4
+
5
+ describe("crdToConfig", () => {
6
+ test("converts empty resources to empty backends", () => {
7
+ const config = crdToConfig(new Map());
8
+ expect(config.backends).toEqual({});
9
+ expect(config.port).toBe(3100);
10
+ expect(config.failOpen).toBe(true);
11
+ });
12
+
13
+ test("converts stdio backend", () => {
14
+ const resources = new Map([
15
+ [
16
+ "grafana",
17
+ {
18
+ transport: "stdio" as const,
19
+ command: "uvx",
20
+ args: ["mcp-grafana"],
21
+ env: { GRAFANA_URL: "http://localhost:3333" },
22
+ },
23
+ ],
24
+ ]);
25
+
26
+ const config = crdToConfig(resources);
27
+ expect(config.backends.grafana).toEqual({
28
+ transport: "stdio",
29
+ command: "uvx",
30
+ args: ["mcp-grafana"],
31
+ env: { GRAFANA_URL: "http://localhost:3333" },
32
+ url: undefined,
33
+ headers: undefined,
34
+ allowedRoles: undefined,
35
+ allowedTeams: undefined,
36
+ });
37
+ });
38
+
39
+ test("converts http backend with headers", () => {
40
+ const resources = new Map([
41
+ [
42
+ "remote",
43
+ {
44
+ transport: "http" as const,
45
+ url: "https://mcp.example.com/mcp",
46
+ headers: { Authorization: "Bearer token" },
47
+ },
48
+ ],
49
+ ]);
50
+
51
+ const config = crdToConfig(resources);
52
+ expect(config.backends.remote.transport).toBe("http");
53
+ expect(config.backends.remote.url).toBe("https://mcp.example.com/mcp");
54
+ expect(config.backends.remote.headers).toEqual({
55
+ Authorization: "Bearer token",
56
+ });
57
+ });
58
+
59
+ test("converts multiple backends", () => {
60
+ const resources = new Map([
61
+ ["grafana", { transport: "stdio" as const, command: "uvx", args: ["mcp-grafana"] }],
62
+ [
63
+ "github",
64
+ {
65
+ transport: "stdio" as const,
66
+ command: "docker",
67
+ args: ["run", "-i", "ghcr.io/github/github-mcp-server"],
68
+ },
69
+ ],
70
+ ]);
71
+
72
+ const config = crdToConfig(resources);
73
+ expect(Object.keys(config.backends)).toHaveLength(2);
74
+ expect(config.backends.grafana.command).toBe("uvx");
75
+ expect(config.backends.github.command).toBe("docker");
76
+ });
77
+
78
+ test("preserves access control fields", () => {
79
+ const resources = new Map([
80
+ [
81
+ "restricted",
82
+ {
83
+ transport: "stdio" as const,
84
+ command: "mcp-server",
85
+ allowedRoles: ["admin"],
86
+ allowedTeams: ["platform"],
87
+ },
88
+ ],
89
+ ]);
90
+
91
+ const config = crdToConfig(resources);
92
+ expect(config.backends.restricted.allowedRoles).toEqual(["admin"]);
93
+ expect(config.backends.restricted.allowedTeams).toEqual(["platform"]);
94
+ });
95
+
96
+ test("applies base config overrides", () => {
97
+ const resources = new Map();
98
+ const config = crdToConfig(resources, {
99
+ port: 4000,
100
+ authToken: "my-token",
101
+ sessionTtlMinutes: 60,
102
+ });
103
+
104
+ expect(config.port).toBe(4000);
105
+ expect(config.authToken).toBe("my-token");
106
+ expect(config.sessionTtlMinutes).toBe(60);
107
+ });
108
+ });