mcpico 0.1.1 → 0.2.0
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/README.md +123 -2
- package/dist/auth-types.d.ts +54 -0
- package/dist/auth-types.js +9 -0
- package/dist/auth-types.js.map +1 -0
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +122 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js.map +1 -1
- package/dist/discoverer.d.ts +2 -1
- package/dist/discoverer.js +15 -3
- package/dist/discoverer.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/oauth-provider.d.ts +19 -0
- package/dist/oauth-provider.js +79 -0
- package/dist/oauth-provider.js.map +1 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +132 -57
- package/dist/server.js.map +1 -1
- package/dist/token-store.d.ts +11 -0
- package/dist/token-store.js +94 -0
- package/dist/token-store.js.map +1 -0
- package/package.json +2 -1
- package/src/auth-types.ts +71 -0
- package/src/auth.test.ts +218 -0
- package/src/auth.ts +159 -0
- package/src/config.ts +14 -0
- package/src/discoverer.ts +25 -5
- package/src/help.test.ts +40 -0
- package/src/index.ts +1 -1
- package/src/oauth-provider.ts +110 -0
- package/src/server.test.ts +324 -0
- package/src/server.ts +165 -75
- package/src/token-store.test.ts +154 -0
- package/src/token-store.ts +109 -0
- package/vitest.config.ts +7 -4
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { textResult, mergeGroups, handleToolCall, validateAllServerConfigs, buildGroupDescription } from "./server.js";
|
|
3
|
+
import type { ToolGroup } from "./grouper.js";
|
|
4
|
+
import type { DiscoveredServer, UpstreamTool } from "./discoverer.js";
|
|
5
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
|
|
7
|
+
function makeTool(overrides: Partial<UpstreamTool> = {}): UpstreamTool {
|
|
8
|
+
return {
|
|
9
|
+
name: "test_tool",
|
|
10
|
+
description: "A test tool",
|
|
11
|
+
inputSchema: { type: "object", properties: {} },
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeGroup(overrides: Partial<ToolGroup> = {}): ToolGroup {
|
|
17
|
+
return {
|
|
18
|
+
groupName: "test_group",
|
|
19
|
+
serverName: "test-server",
|
|
20
|
+
tools: [makeTool()],
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeServer(overrides: Partial<DiscoveredServer> = {}): DiscoveredServer {
|
|
26
|
+
return {
|
|
27
|
+
name: "test-server",
|
|
28
|
+
tools: [makeTool()],
|
|
29
|
+
resources: [],
|
|
30
|
+
prompts: [],
|
|
31
|
+
client: {} as any,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeTextResult(text: string): CallToolResult {
|
|
37
|
+
return { content: [{ type: "text", text }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("textResult", () => {
|
|
41
|
+
it("wraps text in content array", () => {
|
|
42
|
+
const result = textResult("hello");
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
content: [{ type: "text", text: "hello" }],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles empty string", () => {
|
|
49
|
+
const result = textResult("");
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
content: [{ type: "text", text: "" }],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("mergeGroups", () => {
|
|
57
|
+
it("returns empty map for empty input", () => {
|
|
58
|
+
const result = mergeGroups([]);
|
|
59
|
+
expect(result.size).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("preserves single group", () => {
|
|
63
|
+
const group = makeGroup({ groupName: "filesystem" });
|
|
64
|
+
const result = mergeGroups([group]);
|
|
65
|
+
expect(result.size).toBe(1);
|
|
66
|
+
const entry = result.get("filesystem")!;
|
|
67
|
+
expect(entry.groupName).toBe("filesystem");
|
|
68
|
+
expect(entry.tools).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps different groups separate", () => {
|
|
72
|
+
const groups = [
|
|
73
|
+
makeGroup({ groupName: "filesystem" }),
|
|
74
|
+
makeGroup({ groupName: "database" }),
|
|
75
|
+
];
|
|
76
|
+
const result = mergeGroups(groups);
|
|
77
|
+
expect(result.size).toBe(2);
|
|
78
|
+
expect(result.has("filesystem")).toBe(true);
|
|
79
|
+
expect(result.has("database")).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("merges groups with same name across servers", () => {
|
|
83
|
+
const groups = [
|
|
84
|
+
makeGroup({
|
|
85
|
+
groupName: "filesystem",
|
|
86
|
+
serverName: "server-a",
|
|
87
|
+
tools: [makeTool({ name: "read" })],
|
|
88
|
+
}),
|
|
89
|
+
makeGroup({
|
|
90
|
+
groupName: "filesystem",
|
|
91
|
+
serverName: "server-b",
|
|
92
|
+
tools: [makeTool({ name: "write" })],
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
const result = mergeGroups(groups);
|
|
96
|
+
expect(result.size).toBe(1);
|
|
97
|
+
const merged = result.get("filesystem")!;
|
|
98
|
+
expect(merged.tools).toHaveLength(2);
|
|
99
|
+
expect(merged.tools.map((t) => t.name)).toEqual(["read", "write"]);
|
|
100
|
+
expect(merged.serverName).toContain("server-a");
|
|
101
|
+
expect(merged.serverName).toContain("server-b");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not duplicate server name when already present", () => {
|
|
105
|
+
const groups = [
|
|
106
|
+
makeGroup({
|
|
107
|
+
groupName: "filesystem",
|
|
108
|
+
serverName: "server-a + server-b",
|
|
109
|
+
tools: [makeTool({ name: "read" })],
|
|
110
|
+
}),
|
|
111
|
+
makeGroup({
|
|
112
|
+
groupName: "filesystem",
|
|
113
|
+
serverName: "server-b",
|
|
114
|
+
tools: [makeTool({ name: "write" })],
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
const result = mergeGroups(groups);
|
|
118
|
+
const merged = result.get("filesystem")!;
|
|
119
|
+
// server-b is already in "server-a + server-b", should not be added again
|
|
120
|
+
expect(merged.serverName).toBe("server-a + server-b");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("validateAllServerConfigs", () => {
|
|
125
|
+
it("returns empty for valid configs", () => {
|
|
126
|
+
const errors = validateAllServerConfigs([
|
|
127
|
+
{ name: "server-a", transport: { type: "stdio" as const, command: "echo" } },
|
|
128
|
+
]);
|
|
129
|
+
expect(errors).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns errors for invalid configs", () => {
|
|
133
|
+
const errors = validateAllServerConfigs([
|
|
134
|
+
{ name: "", transport: { type: "stdio" as const, command: "" } },
|
|
135
|
+
]);
|
|
136
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("validates multiple configs and collects all errors", () => {
|
|
140
|
+
const errors = validateAllServerConfigs([
|
|
141
|
+
{ name: "", transport: { type: "stdio" as const, command: "" } },
|
|
142
|
+
{ name: "missing-transport" } as any,
|
|
143
|
+
]);
|
|
144
|
+
expect(errors.length).toBe(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("handles empty servers array", () => {
|
|
148
|
+
const errors = validateAllServerConfigs([]);
|
|
149
|
+
expect(errors).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("buildGroupDescription", () => {
|
|
154
|
+
it("includes group name", () => {
|
|
155
|
+
const desc = buildGroupDescription(makeGroup({ groupName: "filesystem" }));
|
|
156
|
+
expect(desc).toContain("MCPico filesystem");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows singular for 1 tool", () => {
|
|
160
|
+
const desc = buildGroupDescription(makeGroup());
|
|
161
|
+
expect(desc).toContain("1 tool");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("shows plural for multiple tools", () => {
|
|
165
|
+
const group = makeGroup({
|
|
166
|
+
tools: [makeTool({ name: "a" }), makeTool({ name: "b" })],
|
|
167
|
+
});
|
|
168
|
+
const desc = buildGroupDescription(group);
|
|
169
|
+
expect(desc).toContain("2 tools");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("includes server name", () => {
|
|
173
|
+
const desc = buildGroupDescription(makeGroup({ serverName: "my-upstream" }));
|
|
174
|
+
expect(desc).toContain("Source: my-upstream");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("includes usage hints", () => {
|
|
178
|
+
const desc = buildGroupDescription(makeGroup());
|
|
179
|
+
expect(desc).toContain("Use 'help'");
|
|
180
|
+
expect(desc).toContain("<subcommand>");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("handleToolCall", () => {
|
|
185
|
+
const servers = [makeServer()];
|
|
186
|
+
const group = makeGroup();
|
|
187
|
+
const helpText = "Help text here";
|
|
188
|
+
|
|
189
|
+
it("returns help for 'help' command", async () => {
|
|
190
|
+
const result = await handleToolCall("help", group, servers, helpText);
|
|
191
|
+
expect(result).toEqual(makeTextResult(helpText));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("returns help for empty command", async () => {
|
|
195
|
+
const result = await handleToolCall("", group, servers, helpText);
|
|
196
|
+
expect(result).toEqual(makeTextResult(helpText));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns help for whitespace command", async () => {
|
|
200
|
+
const result = await handleToolCall(" ", group, servers, helpText);
|
|
201
|
+
expect(result).toEqual(makeTextResult(helpText));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns parse error for invalid JSON args", async () => {
|
|
205
|
+
const result = await handleToolCall(
|
|
206
|
+
"test_tool {bad json}",
|
|
207
|
+
group,
|
|
208
|
+
servers,
|
|
209
|
+
helpText
|
|
210
|
+
);
|
|
211
|
+
expect(result.content?.[0]).toBeDefined();
|
|
212
|
+
if (result.content?.[0] && "text" in result.content[0]) {
|
|
213
|
+
expect(result.content[0].text).toContain("Could not parse arguments as JSON");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns error for unknown subcommand", async () => {
|
|
218
|
+
const result = await handleToolCall(
|
|
219
|
+
"nonexistent",
|
|
220
|
+
group,
|
|
221
|
+
servers,
|
|
222
|
+
helpText
|
|
223
|
+
);
|
|
224
|
+
expect(result.content?.[0]).toBeDefined();
|
|
225
|
+
if (result.content?.[0] && "text" in result.content[0]) {
|
|
226
|
+
expect(result.content[0].text).toContain('Unknown subcommand: "nonexistent"');
|
|
227
|
+
expect(result.content[0].text).toContain("test_tool");
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("returns internal error when server not found for valid tool", async () => {
|
|
232
|
+
// Tool exists in group but no server has it
|
|
233
|
+
const orphanTool = makeTool({ name: "orphan_tool" });
|
|
234
|
+
const orphanGroup = makeGroup({ tools: [orphanTool] });
|
|
235
|
+
const emptyServers: DiscoveredServer[] = [];
|
|
236
|
+
|
|
237
|
+
const result = await handleToolCall(
|
|
238
|
+
"orphan_tool",
|
|
239
|
+
orphanGroup,
|
|
240
|
+
emptyServers,
|
|
241
|
+
helpText
|
|
242
|
+
);
|
|
243
|
+
expect(result.content?.[0]).toBeDefined();
|
|
244
|
+
if (result.content?.[0] && "text" in result.content[0]) {
|
|
245
|
+
expect(result.content[0].text).toContain("Internal error");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("dispatches to forwardFn for valid tool", async () => {
|
|
250
|
+
const forwardFn = vi.fn().mockResolvedValue(makeTextResult("forwarded!"));
|
|
251
|
+
|
|
252
|
+
const result = await handleToolCall(
|
|
253
|
+
"test_tool",
|
|
254
|
+
group,
|
|
255
|
+
servers,
|
|
256
|
+
helpText,
|
|
257
|
+
forwardFn
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
expect(forwardFn).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(forwardFn).toHaveBeenCalledWith(servers[0], "test_tool", {});
|
|
262
|
+
expect(result).toEqual(makeTextResult("forwarded!"));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("passes parsed args to forwardFn", async () => {
|
|
266
|
+
const forwardFn = vi.fn().mockResolvedValue(makeTextResult("ok"));
|
|
267
|
+
|
|
268
|
+
await handleToolCall(
|
|
269
|
+
'test_tool {"key":"value","count":42}',
|
|
270
|
+
group,
|
|
271
|
+
servers,
|
|
272
|
+
helpText,
|
|
273
|
+
forwardFn
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(forwardFn).toHaveBeenCalledWith(servers[0], "test_tool", {
|
|
277
|
+
key: "value",
|
|
278
|
+
count: 42,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns error when forwardFn throws", async () => {
|
|
283
|
+
const forwardFn = vi.fn().mockRejectedValue(new Error("Connection lost"));
|
|
284
|
+
|
|
285
|
+
const result = await handleToolCall(
|
|
286
|
+
"test_tool",
|
|
287
|
+
group,
|
|
288
|
+
servers,
|
|
289
|
+
helpText,
|
|
290
|
+
forwardFn
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(result.content?.[0]).toBeDefined();
|
|
294
|
+
if (result.content?.[0] && "text" in result.content[0]) {
|
|
295
|
+
expect(result.content[0].text).toContain('Error calling "test_tool"');
|
|
296
|
+
expect(result.content[0].text).toContain("Connection lost");
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("finds correct server when multiple servers exist", async () => {
|
|
301
|
+
const serverA = makeServer({
|
|
302
|
+
name: "server-a",
|
|
303
|
+
tools: [makeTool({ name: "tool_a" })],
|
|
304
|
+
});
|
|
305
|
+
const serverB = makeServer({
|
|
306
|
+
name: "server-b",
|
|
307
|
+
tools: [makeTool({ name: "tool_b" })],
|
|
308
|
+
});
|
|
309
|
+
const multiGroup = makeGroup({
|
|
310
|
+
tools: [makeTool({ name: "tool_a" }), makeTool({ name: "tool_b" })],
|
|
311
|
+
});
|
|
312
|
+
const forwardFn = vi.fn().mockResolvedValue(makeTextResult("ok"));
|
|
313
|
+
|
|
314
|
+
await handleToolCall(
|
|
315
|
+
"tool_b",
|
|
316
|
+
multiGroup,
|
|
317
|
+
[serverA, serverB],
|
|
318
|
+
helpText,
|
|
319
|
+
forwardFn
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(forwardFn).toHaveBeenCalledWith(serverB, "tool_b", {});
|
|
323
|
+
});
|
|
324
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { createServer } from "node:http";
|
|
3
5
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
6
|
import { z } from "zod/v4";
|
|
5
7
|
import type { MCPicoConfig } from "./config.js";
|
|
@@ -13,14 +15,125 @@ import { groupTools, type ToolGroup } from "./grouper.js";
|
|
|
13
15
|
import { parseCommand } from "./parser.js";
|
|
14
16
|
import { generateHelpText } from "./help.js";
|
|
15
17
|
import { forwardToolCall } from "./proxy.js";
|
|
18
|
+
import type { ResolvedListenAuth } from "./auth-types.js";
|
|
19
|
+
import { resolveUpstreamAuth, resolveListenAuth, extractBearerToken, validateBearerToken, sendUnauthorized } from "./auth.js";
|
|
16
20
|
|
|
17
21
|
/** Helper: create a simple text content result */
|
|
18
|
-
function textResult(text: string): CallToolResult {
|
|
22
|
+
export function textResult(text: string): CallToolResult {
|
|
19
23
|
return {
|
|
20
24
|
content: [{ type: "text" as const, text }],
|
|
21
25
|
};
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Merge tool groups from multiple upstream servers.
|
|
30
|
+
*
|
|
31
|
+
* Groups with the same name are merged:
|
|
32
|
+
* - Tools are concatenated
|
|
33
|
+
* - serverName is joined with " + "
|
|
34
|
+
*/
|
|
35
|
+
export function mergeGroups(allGroups: ToolGroup[]): Map<string, ToolGroup> {
|
|
36
|
+
const mergedGroups = new Map<string, ToolGroup>();
|
|
37
|
+
for (const group of allGroups) {
|
|
38
|
+
const existing = mergedGroups.get(group.groupName);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.tools.push(...group.tools);
|
|
41
|
+
if (!existing.serverName.includes(group.serverName)) {
|
|
42
|
+
existing.serverName += ` + ${group.serverName}`;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
mergedGroups.set(group.groupName, { ...group });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return mergedGroups;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handle a tool call command for a given group.
|
|
53
|
+
*
|
|
54
|
+
* Dispatches: help → error → unknown subcommand → forward to upstream server.
|
|
55
|
+
*
|
|
56
|
+
* Exported for testing; allows injecting a mock forwardFn to avoid
|
|
57
|
+
* connecting to real upstream servers.
|
|
58
|
+
*/
|
|
59
|
+
export async function handleToolCall(
|
|
60
|
+
command: string,
|
|
61
|
+
group: ToolGroup,
|
|
62
|
+
servers: DiscoveredServer[],
|
|
63
|
+
helpText: string,
|
|
64
|
+
forwardFn: (
|
|
65
|
+
server: DiscoveredServer,
|
|
66
|
+
toolName: string,
|
|
67
|
+
args: Record<string, unknown>
|
|
68
|
+
) => Promise<CallToolResult> = forwardToolCall
|
|
69
|
+
): Promise<CallToolResult> {
|
|
70
|
+
const parsed = parseCommand(command);
|
|
71
|
+
|
|
72
|
+
if (parsed.isHelp) {
|
|
73
|
+
return textResult(helpText);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (parsed.error) {
|
|
77
|
+
return textResult(parsed.error);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const toolName = parsed.subcommand!;
|
|
81
|
+
const upstreamTool = group.tools.find((t) => t.name === toolName);
|
|
82
|
+
if (!upstreamTool) {
|
|
83
|
+
const available = group.tools.map((t) => t.name).join(", ");
|
|
84
|
+
return textResult(
|
|
85
|
+
`Unknown subcommand: "${toolName}"\n\nAvailable in ${group.groupName}: ${available}\n\nUse 'help' for full documentation.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const serverForTool = servers.find((s) =>
|
|
90
|
+
s.tools.some((t) => t.name === toolName)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (!serverForTool) {
|
|
94
|
+
return textResult(
|
|
95
|
+
`Internal error: could not find upstream server for tool "${toolName}"`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const result = await forwardFn(serverForTool, toolName, parsed.args);
|
|
101
|
+
return result;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return textResult(
|
|
104
|
+
`Error calling "${toolName}": ${(err as Error).message}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the description string for a tool group.
|
|
111
|
+
*/
|
|
112
|
+
export function buildGroupDescription(group: ToolGroup): string {
|
|
113
|
+
const toolCount = group.tools.length;
|
|
114
|
+
return [
|
|
115
|
+
`MCPico ${group.groupName} — ${toolCount} tool${toolCount === 1 ? "" : "s"}`,
|
|
116
|
+
`Source: ${group.serverName}`,
|
|
117
|
+
`Use 'help' to see all subcommands and their parameters.`,
|
|
118
|
+
`Format: <subcommand> {"key":"value",...}`,
|
|
119
|
+
].join(" | ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate all server configs, returning an array of error messages.
|
|
124
|
+
* Returns empty array if all configs are valid.
|
|
125
|
+
*/
|
|
126
|
+
export function validateAllServerConfigs(servers: MCPicoConfig["servers"]): string[] {
|
|
127
|
+
const errors: string[] = [];
|
|
128
|
+
for (const serverConfig of servers) {
|
|
129
|
+
const err = validateServerConfig(serverConfig);
|
|
130
|
+
if (err) {
|
|
131
|
+
errors.push(err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
24
137
|
/**
|
|
25
138
|
* Start the MCPico proxy server.
|
|
26
139
|
*
|
|
@@ -36,13 +149,7 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
36
149
|
const allGroups: ToolGroup[] = [];
|
|
37
150
|
|
|
38
151
|
// Validate server configs
|
|
39
|
-
const validationErrors
|
|
40
|
-
for (const serverConfig of config.servers) {
|
|
41
|
-
const err = validateServerConfig(serverConfig);
|
|
42
|
-
if (err) {
|
|
43
|
-
validationErrors.push(err);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
152
|
+
const validationErrors = validateAllServerConfigs(config.servers);
|
|
46
153
|
if (validationErrors.length > 0) {
|
|
47
154
|
console.error("Configuration errors:");
|
|
48
155
|
for (const err of validationErrors) {
|
|
@@ -63,10 +170,14 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
63
170
|
? serverConfig.transport.url
|
|
64
171
|
: serverConfig.transport.command;
|
|
65
172
|
console.error(` Connecting to "${serverConfig.name}" (${serverConfig.transport.type}: ${transportLabel})...`);
|
|
173
|
+
const resolvedAuth = serverConfig.auth
|
|
174
|
+
? resolveUpstreamAuth(serverConfig.auth)
|
|
175
|
+
: undefined;
|
|
66
176
|
const discovered = await discoverServer(
|
|
67
177
|
serverConfig.name,
|
|
68
178
|
serverConfig.transport,
|
|
69
|
-
serverConfig.connectTimeoutMs
|
|
179
|
+
serverConfig.connectTimeoutMs,
|
|
180
|
+
resolvedAuth
|
|
70
181
|
);
|
|
71
182
|
servers.push(discovered);
|
|
72
183
|
|
|
@@ -96,22 +207,11 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
96
207
|
}
|
|
97
208
|
|
|
98
209
|
// Build lookup: groupName → ToolGroup (merged across servers)
|
|
99
|
-
const mergedGroups =
|
|
100
|
-
for (const group of allGroups) {
|
|
101
|
-
const existing = mergedGroups.get(group.groupName);
|
|
102
|
-
if (existing) {
|
|
103
|
-
existing.tools.push(...group.tools);
|
|
104
|
-
if (!existing.serverName.includes(group.serverName)) {
|
|
105
|
-
existing.serverName += ` + ${group.serverName}`;
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
mergedGroups.set(group.groupName, { ...group });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
210
|
+
const mergedGroups = mergeGroups(allGroups);
|
|
111
211
|
|
|
112
212
|
// Create the MCPico server
|
|
113
213
|
const server = new McpServer(
|
|
114
|
-
{ name: "MCPico", version: "0.
|
|
214
|
+
{ name: "MCPico", version: "0.2.0" },
|
|
115
215
|
{
|
|
116
216
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
117
217
|
instructions:
|
|
@@ -124,12 +224,7 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
124
224
|
// Register each group as a tool
|
|
125
225
|
for (const [groupName, group] of mergedGroups) {
|
|
126
226
|
const toolCount = group.tools.length;
|
|
127
|
-
const description =
|
|
128
|
-
`MCPico ${groupName} — ${toolCount} tool${toolCount === 1 ? "" : "s"}`,
|
|
129
|
-
`Source: ${group.serverName}`,
|
|
130
|
-
`Use 'help' to see all subcommands and their parameters.`,
|
|
131
|
-
`Format: <subcommand> {"key":"value",...}`,
|
|
132
|
-
].join(" | ");
|
|
227
|
+
const description = buildGroupDescription(group);
|
|
133
228
|
|
|
134
229
|
const helpText = generateHelpText(group);
|
|
135
230
|
|
|
@@ -148,47 +243,7 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
148
243
|
},
|
|
149
244
|
},
|
|
150
245
|
async (args: { command: string }) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (parsed.isHelp) {
|
|
154
|
-
return textResult(helpText);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (parsed.error) {
|
|
158
|
-
return textResult(parsed.error);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const toolName = parsed.subcommand!;
|
|
162
|
-
const upstreamTool = group.tools.find((t) => t.name === toolName);
|
|
163
|
-
if (!upstreamTool) {
|
|
164
|
-
const available = group.tools.map((t) => t.name).join(", ");
|
|
165
|
-
return textResult(
|
|
166
|
-
`Unknown subcommand: "${toolName}"\n\nAvailable in ${groupName}: ${available}\n\nUse 'help' for full documentation.`
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const serverForTool = servers.find((s) =>
|
|
171
|
-
s.tools.some((t) => t.name === toolName)
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
if (!serverForTool) {
|
|
175
|
-
return textResult(
|
|
176
|
-
`Internal error: could not find upstream server for tool "${toolName}"`
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const result = await forwardToolCall(
|
|
182
|
-
serverForTool,
|
|
183
|
-
toolName,
|
|
184
|
-
parsed.args
|
|
185
|
-
);
|
|
186
|
-
return result;
|
|
187
|
-
} catch (err) {
|
|
188
|
-
return textResult(
|
|
189
|
-
`Error calling "${toolName}": ${(err as Error).message}`
|
|
190
|
-
);
|
|
191
|
-
}
|
|
246
|
+
return handleToolCall(args.command, group, servers, helpText);
|
|
192
247
|
}
|
|
193
248
|
);
|
|
194
249
|
|
|
@@ -273,13 +328,48 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
273
328
|
console.error(` Registered ${promptCount} prompt(s)`);
|
|
274
329
|
}
|
|
275
330
|
|
|
276
|
-
// Connect to client via
|
|
277
|
-
const
|
|
278
|
-
await server.connect(transport);
|
|
331
|
+
// Connect to client via configured transport
|
|
332
|
+
const listenConfig = config.listen || { type: "stdio" };
|
|
279
333
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
334
|
+
if (listenConfig.type === "sse") {
|
|
335
|
+
// Resolve listen auth
|
|
336
|
+
let listenAuth: ResolvedListenAuth | undefined;
|
|
337
|
+
if (listenConfig.auth) {
|
|
338
|
+
listenAuth = resolveListenAuth(listenConfig.auth);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const transport = new StreamableHTTPServerTransport();
|
|
342
|
+
const httpServer = createServer(async (req, res) => {
|
|
343
|
+
// Auth check
|
|
344
|
+
if (listenAuth) {
|
|
345
|
+
const providedToken = extractBearerToken(req);
|
|
346
|
+
if (!validateBearerToken(providedToken, listenAuth.token)) {
|
|
347
|
+
sendUnauthorized(res);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Collect body for handleRequest
|
|
353
|
+
const chunks: Buffer[] = [];
|
|
354
|
+
for await (const chunk of req) {
|
|
355
|
+
chunks.push(chunk);
|
|
356
|
+
}
|
|
357
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks).toString() : undefined;
|
|
358
|
+
await transport.handleRequest(req, res, body);
|
|
359
|
+
});
|
|
360
|
+
await server.connect(transport);
|
|
361
|
+
const host = listenConfig.host || "localhost";
|
|
362
|
+
httpServer.listen(listenConfig.port, host);
|
|
363
|
+
console.error(
|
|
364
|
+
`MCPico ready — ${mergedGroups.size} group(s), ${servers.length} upstream server(s) — listening on http://${host}:${listenConfig.port}`
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
const transport = new StdioServerTransport();
|
|
368
|
+
await server.connect(transport);
|
|
369
|
+
console.error(
|
|
370
|
+
`MCPico ready — ${mergedGroups.size} group(s), ${servers.length} upstream server(s)`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
283
373
|
|
|
284
374
|
// Handle shutdown
|
|
285
375
|
let shuttingDown = false;
|