@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.
@@ -0,0 +1,223 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { extractTools, buildRequest, type OpenApiTool } from "./openapi.js";
4
+
5
+ const minimalSpec = {
6
+ paths: {
7
+ "/pets": {
8
+ get: {
9
+ operationId: "listPets",
10
+ summary: "List all pets",
11
+ parameters: [
12
+ {
13
+ name: "limit",
14
+ in: "query" as const,
15
+ required: false,
16
+ schema: { type: "integer" },
17
+ },
18
+ ],
19
+ },
20
+ post: {
21
+ operationId: "createPet",
22
+ summary: "Create a pet",
23
+ requestBody: {
24
+ content: {
25
+ "application/json": {
26
+ schema: {
27
+ type: "object",
28
+ properties: {
29
+ name: { type: "string" },
30
+ tag: { type: "string" },
31
+ },
32
+ required: ["name"],
33
+ },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ "/pets/{petId}": {
40
+ get: {
41
+ operationId: "getPet",
42
+ summary: "Get a pet by ID",
43
+ parameters: [
44
+ {
45
+ name: "petId",
46
+ in: "path" as const,
47
+ required: true,
48
+ schema: { type: "string" },
49
+ },
50
+ ],
51
+ },
52
+ },
53
+ "/internal": {
54
+ get: {
55
+ // No operationId — should be skipped
56
+ summary: "Internal endpoint",
57
+ },
58
+ },
59
+ },
60
+ };
61
+
62
+ describe("extractTools", () => {
63
+ test("extracts tools from operations with operationId", () => {
64
+ const tools = extractTools(minimalSpec);
65
+ expect(tools).toHaveLength(3);
66
+ expect(tools.map((t) => t.name)).toEqual(["listPets", "createPet", "getPet"]);
67
+ });
68
+
69
+ test("skips operations without operationId", () => {
70
+ const tools = extractTools(minimalSpec);
71
+ expect(tools.find((t) => t.name === "Internal endpoint")).toBeUndefined();
72
+ });
73
+
74
+ test("maps path parameters as required", () => {
75
+ const tools = extractTools(minimalSpec);
76
+ const getPet = tools.find((t) => t.name === "getPet")!;
77
+ expect(getPet.inputSchema.required).toEqual(["petId"]);
78
+ expect((getPet.inputSchema.properties as Record<string, unknown>).petId).toBeTruthy();
79
+ });
80
+
81
+ test("maps query parameters", () => {
82
+ const tools = extractTools(minimalSpec);
83
+ const listPets = tools.find((t) => t.name === "listPets")!;
84
+ expect((listPets.inputSchema.properties as Record<string, unknown>).limit).toBeTruthy();
85
+ });
86
+
87
+ test("maps request body properties", () => {
88
+ const tools = extractTools(minimalSpec);
89
+ const createPet = tools.find((t) => t.name === "createPet")!;
90
+ const props = createPet.inputSchema.properties as Record<string, unknown>;
91
+ expect(props.name).toBeTruthy();
92
+ expect(props.tag).toBeTruthy();
93
+ expect(createPet.inputSchema.required).toContain("name");
94
+ });
95
+
96
+ test("sets correct HTTP method", () => {
97
+ const tools = extractTools(minimalSpec);
98
+ expect(tools.find((t) => t.name === "listPets")!.method).toBe("GET");
99
+ expect(tools.find((t) => t.name === "createPet")!.method).toBe("POST");
100
+ });
101
+ });
102
+
103
+ describe("buildRequest", () => {
104
+ test("substitutes path parameters", () => {
105
+ const tool: OpenApiTool = {
106
+ name: "getPet",
107
+ description: "Get a pet",
108
+ inputSchema: {},
109
+ method: "GET",
110
+ path: "/pets/{petId}",
111
+ parameterMap: [{ name: "petId", in: "path", required: true }],
112
+ };
113
+ const { url } = buildRequest(tool, { petId: "123" }, "https://api.example.com");
114
+ expect(url).toBe("https://api.example.com/pets/123");
115
+ });
116
+
117
+ test("appends query parameters", () => {
118
+ const tool: OpenApiTool = {
119
+ name: "listPets",
120
+ description: "List pets",
121
+ inputSchema: {},
122
+ method: "GET",
123
+ path: "/pets",
124
+ parameterMap: [{ name: "limit", in: "query", required: false }],
125
+ };
126
+ const { url } = buildRequest(tool, { limit: 10 }, "https://api.example.com");
127
+ expect(url).toBe("https://api.example.com/pets?limit=10");
128
+ });
129
+
130
+ test("sends body as JSON for POST", () => {
131
+ const tool: OpenApiTool = {
132
+ name: "createPet",
133
+ description: "Create a pet",
134
+ inputSchema: {},
135
+ method: "POST",
136
+ path: "/pets",
137
+ parameterMap: [
138
+ { name: "name", in: "body", required: true },
139
+ { name: "tag", in: "body", required: false },
140
+ ],
141
+ };
142
+ const { url, init } = buildRequest(
143
+ tool,
144
+ { name: "Fido", tag: "dog" },
145
+ "https://api.example.com",
146
+ );
147
+ expect(url).toBe("https://api.example.com/pets");
148
+ expect(init.method).toBe("POST");
149
+ expect(JSON.parse(init.body as string)).toEqual({
150
+ name: "Fido",
151
+ tag: "dog",
152
+ });
153
+ });
154
+
155
+ test("merges custom headers", () => {
156
+ const tool: OpenApiTool = {
157
+ name: "listPets",
158
+ description: "",
159
+ inputSchema: {},
160
+ method: "GET",
161
+ path: "/pets",
162
+ parameterMap: [],
163
+ };
164
+ const { init } = buildRequest(tool, {}, "https://api.example.com", {
165
+ Authorization: "Bearer token",
166
+ });
167
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer token");
168
+ });
169
+
170
+ test("strips trailing slash from baseUrl", () => {
171
+ const tool: OpenApiTool = {
172
+ name: "listPets",
173
+ description: "",
174
+ inputSchema: {},
175
+ method: "GET",
176
+ path: "/pets",
177
+ parameterMap: [],
178
+ };
179
+ const { url } = buildRequest(tool, {}, "https://api.example.com/");
180
+ expect(url).toBe("https://api.example.com/pets");
181
+ });
182
+ });
183
+
184
+ describe("$ref resolution", () => {
185
+ test("resolves component schema references", () => {
186
+ const specWithRefs = {
187
+ paths: {
188
+ "/pets": {
189
+ post: {
190
+ operationId: "createPet",
191
+ summary: "Create",
192
+ requestBody: {
193
+ content: {
194
+ "application/json": {
195
+ schema: { $ref: "#/components/schemas/Pet" },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ },
201
+ },
202
+ components: {
203
+ schemas: {
204
+ Pet: {
205
+ type: "object",
206
+ properties: {
207
+ name: { type: "string" },
208
+ age: { type: "integer" },
209
+ },
210
+ required: ["name"],
211
+ },
212
+ },
213
+ },
214
+ };
215
+
216
+ const tools = extractTools(specWithRefs);
217
+ expect(tools).toHaveLength(1);
218
+ const props = tools[0].inputSchema.properties as Record<string, unknown>;
219
+ expect(props.name).toBeTruthy();
220
+ expect(props.age).toBeTruthy();
221
+ expect(tools[0].inputSchema.required).toContain("name");
222
+ });
223
+ });
package/src/openapi.ts ADDED
@@ -0,0 +1,253 @@
1
+ import type { Backend, ToolInfo } from "./backends.js";
2
+ import type { BackendConfig } from "./config.js";
3
+
4
+ interface OpenApiSpec {
5
+ paths: Record<string, Record<string, OpenApiOperation>>;
6
+ servers?: Array<{ url: string }>;
7
+ components?: { schemas?: Record<string, unknown> };
8
+ }
9
+
10
+ interface OpenApiOperation {
11
+ operationId?: string;
12
+ summary?: string;
13
+ description?: string;
14
+ parameters?: OpenApiParameter[];
15
+ requestBody?: { content?: { "application/json"?: { schema?: unknown } } };
16
+ }
17
+
18
+ interface OpenApiParameter {
19
+ name: string;
20
+ in: "path" | "query" | "header";
21
+ required?: boolean;
22
+ schema?: { type?: string; description?: string };
23
+ description?: string;
24
+ }
25
+
26
+ interface ParameterMapping {
27
+ name: string;
28
+ in: "path" | "query" | "header" | "body";
29
+ required: boolean;
30
+ }
31
+
32
+ export interface OpenApiTool {
33
+ name: string;
34
+ description: string;
35
+ inputSchema: Record<string, unknown>;
36
+ method: string;
37
+ path: string;
38
+ parameterMap: ParameterMapping[];
39
+ }
40
+
41
+ /** Fetch and parse an OpenAPI 3.x spec */
42
+ export async function loadOpenApiSpec(specUrl: string): Promise<OpenApiSpec> {
43
+ const res = await fetch(specUrl);
44
+ if (!res.ok) throw new Error(`Failed to fetch OpenAPI spec: ${res.status}`);
45
+ return res.json();
46
+ }
47
+
48
+ /** Resolve a $ref to a schema definition */
49
+ function resolveRef(ref: string, spec: OpenApiSpec): unknown {
50
+ const path = ref.replace("#/", "").split("/");
51
+ let current: unknown = spec;
52
+ for (const segment of path) {
53
+ current = (current as Record<string, unknown>)?.[segment];
54
+ }
55
+ return current ?? {};
56
+ }
57
+
58
+ /** Recursively resolve $ref in a schema */
59
+ function resolveSchema(schema: unknown, spec: OpenApiSpec): unknown {
60
+ if (!schema || typeof schema !== "object") return schema;
61
+ if (Array.isArray(schema)) return schema.map((item) => resolveSchema(item, spec));
62
+ const obj = schema as Record<string, unknown>;
63
+ if (obj.$ref && typeof obj.$ref === "string") {
64
+ return resolveSchema(resolveRef(obj.$ref, spec), spec);
65
+ }
66
+ const resolved: Record<string, unknown> = {};
67
+ for (const [k, v] of Object.entries(obj)) {
68
+ if (typeof v === "object" && v !== null) {
69
+ resolved[k] = resolveSchema(v, spec);
70
+ } else {
71
+ resolved[k] = v;
72
+ }
73
+ }
74
+ return resolved;
75
+ }
76
+
77
+ /** Extract MCP tools from an OpenAPI spec */
78
+ export function extractTools(spec: OpenApiSpec): OpenApiTool[] {
79
+ const tools: OpenApiTool[] = [];
80
+
81
+ for (const [path, methods] of Object.entries(spec.paths ?? {})) {
82
+ for (const [method, operation] of Object.entries(methods)) {
83
+ if (!operation.operationId) continue;
84
+
85
+ const parameterMap: ParameterMapping[] = [];
86
+ const properties: Record<string, unknown> = {};
87
+ const required: string[] = [];
88
+
89
+ // Path, query, header parameters
90
+ for (const param of operation.parameters ?? []) {
91
+ parameterMap.push({
92
+ name: param.name,
93
+ in: param.in,
94
+ required: param.required ?? param.in === "path",
95
+ });
96
+ properties[param.name] = {
97
+ type: param.schema?.type ?? "string",
98
+ description: param.description ?? param.schema?.description,
99
+ };
100
+ if (param.required || param.in === "path") {
101
+ required.push(param.name);
102
+ }
103
+ }
104
+
105
+ // Request body
106
+ const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
107
+ if (bodySchema) {
108
+ const resolved = resolveSchema(bodySchema, spec) as Record<string, unknown>;
109
+ if (resolved.properties) {
110
+ const bodyProps = resolved.properties as Record<string, unknown>;
111
+ const bodyRequired = (resolved.required as string[]) ?? [];
112
+ for (const [name, prop] of Object.entries(bodyProps)) {
113
+ parameterMap.push({
114
+ name,
115
+ in: "body",
116
+ required: bodyRequired.includes(name),
117
+ });
118
+ properties[name] = prop;
119
+ if (bodyRequired.includes(name)) {
120
+ required.push(name);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ tools.push({
127
+ name: operation.operationId,
128
+ description: operation.summary ?? operation.description?.slice(0, 120) ?? "",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties,
132
+ ...(required.length > 0 ? { required } : {}),
133
+ },
134
+ method: method.toUpperCase(),
135
+ path,
136
+ parameterMap,
137
+ });
138
+ }
139
+ }
140
+
141
+ return tools;
142
+ }
143
+
144
+ /** Build an HTTP request from a tool call */
145
+ export function buildRequest(
146
+ tool: OpenApiTool,
147
+ args: Record<string, unknown>,
148
+ baseUrl: string,
149
+ headers?: Record<string, string>,
150
+ ): { url: string; init: RequestInit } {
151
+ let path = tool.path;
152
+ const queryParams = new URLSearchParams();
153
+ const bodyFields: Record<string, unknown> = {};
154
+ const reqHeaders: Record<string, string> = { ...headers };
155
+
156
+ for (const mapping of tool.parameterMap) {
157
+ const value = args[mapping.name];
158
+ if (value === undefined) continue;
159
+
160
+ if (mapping.in === "path") {
161
+ path = path.replace(`{${mapping.name}}`, encodeURIComponent(String(value)));
162
+ } else if (mapping.in === "query") {
163
+ queryParams.set(mapping.name, String(value));
164
+ } else if (mapping.in === "header") {
165
+ reqHeaders[mapping.name] = String(value);
166
+ } else if (mapping.in === "body") {
167
+ bodyFields[mapping.name] = value;
168
+ }
169
+ }
170
+
171
+ const queryString = queryParams.toString();
172
+ const url = `${baseUrl.replace(/\/$/, "")}${path}${queryString ? `?${queryString}` : ""}`;
173
+
174
+ const init: RequestInit = {
175
+ method: tool.method,
176
+ headers: reqHeaders,
177
+ };
178
+
179
+ if (Object.keys(bodyFields).length > 0 && tool.method !== "GET") {
180
+ init.body = JSON.stringify(bodyFields);
181
+ reqHeaders["Content-Type"] = "application/json";
182
+ }
183
+
184
+ return { url, init };
185
+ }
186
+
187
+ /** Execute an OpenAPI tool call */
188
+ async function executeOpenApiTool(
189
+ tool: OpenApiTool,
190
+ args: Record<string, unknown>,
191
+ baseUrl: string,
192
+ headers?: Record<string, string>,
193
+ ): Promise<unknown> {
194
+ const { url, init } = buildRequest(tool, args, baseUrl, headers);
195
+ const res = await fetch(url, init);
196
+ const contentType = res.headers.get("content-type") ?? "";
197
+
198
+ if (!res.ok) {
199
+ const body = contentType.includes("json") ? await res.json() : await res.text();
200
+ return { error: `HTTP ${res.status}`, body };
201
+ }
202
+
203
+ return contentType.includes("json") ? res.json() : res.text();
204
+ }
205
+
206
+ /** Create a Backend from an OpenAPI spec — no MCP client needed */
207
+ export async function createOpenApiBackend(name: string, config: BackendConfig): Promise<Backend> {
208
+ const specUrl = config.specUrl ?? config.url;
209
+ if (!specUrl) throw new Error(`Backend "${name}" missing specUrl or url`);
210
+
211
+ const baseUrl = config.baseUrl ?? specUrl.replace(/\/[^/]*$/, "");
212
+ const spec = await loadOpenApiSpec(specUrl);
213
+ const openApiTools = extractTools(spec);
214
+
215
+ console.log(` ${name}: ${openApiTools.length} tools from OpenAPI spec`);
216
+
217
+ const tools: ToolInfo[] = openApiTools.map((t) => ({
218
+ name: t.name,
219
+ description: t.description,
220
+ inputSchema: t.inputSchema,
221
+ }));
222
+
223
+ // Proxy client that routes callTool to HTTP requests
224
+ const toolMap = new Map(openApiTools.map((t) => [t.name, t]));
225
+ const client = {
226
+ callTool: async (params: { name: string; arguments?: Record<string, unknown> }) => {
227
+ const tool = toolMap.get(params.name);
228
+ if (!tool)
229
+ return {
230
+ content: [{ type: "text", text: `Unknown tool: ${params.name}` }],
231
+ };
232
+ const result = await executeOpenApiTool(
233
+ tool,
234
+ params.arguments ?? {},
235
+ baseUrl,
236
+ config.headers,
237
+ );
238
+ return {
239
+ content: [
240
+ {
241
+ type: "text",
242
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
243
+ },
244
+ ],
245
+ };
246
+ },
247
+ listTools: async () => ({ tools: tools.map((t) => ({ ...t })) }),
248
+ close: async () => {},
249
+ connect: async () => {},
250
+ };
251
+
252
+ return { name, client: client as Backend["client"], tools };
253
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,176 @@
1
+ // Entrypoint for stdio mode — Claude Code runs this directly as a subprocess
2
+ // Usage: mcpx stdio mcpx.json
3
+ // Or: bunx mcpx stdio mcpx.json
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+
8
+ import {
9
+ connectBackends,
10
+ generateTypeDefinitions,
11
+ generateToolListing,
12
+ refreshAllTools,
13
+ type Backend,
14
+ } from "./backends.js";
15
+ import { loadConfig } from "./config.js";
16
+ import { executeCode } from "./executor.js";
17
+
18
+ export async function startStdioServer(configPath: string): Promise<void> {
19
+ const config = loadConfig(configPath);
20
+
21
+ // In stdio mode, log to stderr so stdout stays clean for MCP protocol
22
+ process.stderr.write(`mcpx stdio starting...\n`);
23
+ process.stderr.write(` config: ${configPath}\n`);
24
+ process.stderr.write(` backends: ${Object.keys(config.backends).join(", ")}\n`);
25
+
26
+ const backends = await connectBackends(config.backends);
27
+
28
+ if (backends.size === 0 && !config.failOpen) {
29
+ process.stderr.write("No backends connected. Use failOpen: true to start anyway.\n");
30
+ process.exit(1);
31
+ }
32
+
33
+ if (backends.size === 0) {
34
+ process.stderr.write("Warning: no backends connected (failOpen mode)\n");
35
+ }
36
+
37
+ let typeDefs = generateTypeDefinitions(backends);
38
+ let toolListing = generateToolListing(backends);
39
+ const totalTools = Array.from(backends.values()).reduce((sum, b) => sum + b.tools.length, 0);
40
+
41
+ process.stderr.write(
42
+ `\n${totalTools} tools from ${backends.size} backends -> 2 Code Mode tools\n`,
43
+ );
44
+
45
+ // Periodic tool refresh
46
+ if (config.toolRefreshInterval && config.toolRefreshInterval > 0) {
47
+ setInterval(async () => {
48
+ try {
49
+ await refreshAllTools(backends);
50
+ typeDefs = generateTypeDefinitions(backends);
51
+ toolListing = generateToolListing(backends);
52
+ } catch (err) {
53
+ process.stderr.write(`Tool refresh failed: ${(err as Error).message}\n`);
54
+ }
55
+ }, config.toolRefreshInterval * 1000);
56
+ }
57
+
58
+ const server = createMcpServer(backends, typeDefs, toolListing);
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
61
+
62
+ process.stderr.write("mcpx stdio ready\n");
63
+ }
64
+
65
+ function createMcpServer(
66
+ backends: Map<string, Backend>,
67
+ typeDefs: string,
68
+ toolListing: string,
69
+ ): McpServer {
70
+ const server = new McpServer({
71
+ name: "mcpx",
72
+ version: "0.1.0",
73
+ });
74
+
75
+ server.tool(
76
+ "search",
77
+ `Search available tools across all connected MCP servers. Returns type definitions for matched tools.
78
+
79
+ Available tools:
80
+ ${toolListing}`,
81
+ {
82
+ query: z.string().describe("Search query — tool name, backend name, or keyword"),
83
+ },
84
+ async ({ query }) => {
85
+ const q = query.toLowerCase();
86
+ const matched: string[] = [];
87
+
88
+ for (const [name, backend] of backends) {
89
+ for (const tool of backend.tools) {
90
+ const fullName = `${name}_${tool.name}`;
91
+ const desc = tool.description?.toLowerCase() ?? "";
92
+ if (
93
+ fullName.toLowerCase().includes(q) ||
94
+ desc.includes(q) ||
95
+ name.toLowerCase().includes(q)
96
+ ) {
97
+ const params = tool.inputSchema?.properties
98
+ ? JSON.stringify(tool.inputSchema.properties, null, 2)
99
+ : "{}";
100
+ matched.push(`### ${fullName}\n${tool.description ?? ""}\nParameters: ${params}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ if (matched.length === 0) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text" as const,
110
+ text: `No tools found matching "${query}". Available backends: ${Array.from(backends.keys()).join(", ")}`,
111
+ },
112
+ ],
113
+ };
114
+ }
115
+
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text" as const,
120
+ text: `Found ${matched.length} tools:\n\n${matched.join("\n\n")}`,
121
+ },
122
+ ],
123
+ };
124
+ },
125
+ );
126
+
127
+ server.tool(
128
+ "execute",
129
+ `Execute JavaScript code that calls MCP tools. The code runs in a V8 isolate.
130
+
131
+ Write an async function body. Available tool functions (call with await):
132
+ ${typeDefs}
133
+
134
+ Example (namespace style):
135
+ const result = await grafana.searchDashboards({ query: "pods" });
136
+ return result;
137
+
138
+ Example (classic style):
139
+ const result = await grafana_search_dashboards({ query: "pods" });
140
+ return result;`,
141
+ { code: z.string().describe("JavaScript async function body to execute") },
142
+ async ({ code }) => {
143
+ const result = await executeCode(code, backends);
144
+
145
+ if (result.isErr()) {
146
+ const e = result.error;
147
+ let msg = e.kind === "runtime" ? `Execution failed with code ${e.code}` : e.message;
148
+ if (e.kind === "parse" && e.snippet) {
149
+ msg += `\n\n${e.snippet}`;
150
+ }
151
+ return {
152
+ content: [{ type: "text" as const, text: `Error: ${msg}` }],
153
+ isError: true,
154
+ };
155
+ }
156
+
157
+ const val = result.value.value;
158
+ const text = typeof val === "string" ? val : JSON.stringify(val, null, 2);
159
+ const logText =
160
+ result.value.logs.length > 0
161
+ ? `\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")}`
162
+ : "";
163
+
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text" as const,
168
+ text: text + logText,
169
+ },
170
+ ],
171
+ };
172
+ },
173
+ );
174
+
175
+ return server;
176
+ }