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.
@@ -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: string[] = [];
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 = new Map<string, ToolGroup>();
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.1.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
- const parsed = parseCommand(args.command);
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 stdio
277
- const transport = new StdioServerTransport();
278
- await server.connect(transport);
331
+ // Connect to client via configured transport
332
+ const listenConfig = config.listen || { type: "stdio" };
279
333
 
280
- console.error(
281
- `MCPico ready ${mergedGroups.size} group(s), ${servers.length} upstream server(s)`
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;