mcpico 0.1.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.
Files changed (61) hide show
  1. package/README.md +172 -0
  2. package/dist/config.d.ts +46 -0
  3. package/dist/config.js +32 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/config.test.d.ts +1 -0
  6. package/dist/config.test.js +87 -0
  7. package/dist/config.test.js.map +1 -0
  8. package/dist/discoverer.d.ts +52 -0
  9. package/dist/discoverer.js +84 -0
  10. package/dist/discoverer.js.map +1 -0
  11. package/dist/discoverer.test.d.ts +1 -0
  12. package/dist/discoverer.test.js +178 -0
  13. package/dist/discoverer.test.js.map +1 -0
  14. package/dist/grouper.d.ts +22 -0
  15. package/dist/grouper.js +70 -0
  16. package/dist/grouper.js.map +1 -0
  17. package/dist/grouper.test.d.ts +1 -0
  18. package/dist/grouper.test.js +169 -0
  19. package/dist/grouper.test.js.map +1 -0
  20. package/dist/help.d.ts +5 -0
  21. package/dist/help.js +115 -0
  22. package/dist/help.js.map +1 -0
  23. package/dist/help.test.d.ts +1 -0
  24. package/dist/help.test.js +173 -0
  25. package/dist/help.test.js.map +1 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +90 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/parser.d.ts +28 -0
  30. package/dist/parser.js +60 -0
  31. package/dist/parser.js.map +1 -0
  32. package/dist/parser.test.d.ts +1 -0
  33. package/dist/parser.test.js +142 -0
  34. package/dist/parser.test.js.map +1 -0
  35. package/dist/proxy.d.ts +6 -0
  36. package/dist/proxy.js +10 -0
  37. package/dist/proxy.js.map +1 -0
  38. package/dist/proxy.test.d.ts +1 -0
  39. package/dist/proxy.test.js +61 -0
  40. package/dist/proxy.test.js.map +1 -0
  41. package/dist/server.d.ts +11 -0
  42. package/dist/server.js +212 -0
  43. package/dist/server.js.map +1 -0
  44. package/mcplico.example.json +18 -0
  45. package/package.json +36 -0
  46. package/src/config.test.ts +96 -0
  47. package/src/config.ts +76 -0
  48. package/src/discoverer.test.ts +222 -0
  49. package/src/discoverer.ts +148 -0
  50. package/src/grouper.test.ts +202 -0
  51. package/src/grouper.ts +96 -0
  52. package/src/help.test.ts +197 -0
  53. package/src/help.ts +134 -0
  54. package/src/index.ts +106 -0
  55. package/src/parser.test.ts +173 -0
  56. package/src/parser.ts +78 -0
  57. package/src/proxy.test.ts +77 -0
  58. package/src/proxy.ts +16 -0
  59. package/src/server.ts +299 -0
  60. package/tsconfig.json +18 -0
  61. package/vitest.config.ts +17 -0
package/src/help.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { ToolGroup } from "./grouper.js";
2
+ import type { UpstreamTool } from "./discoverer.js";
3
+
4
+ /**
5
+ * Generate a human-readable property description from a JSON Schema property definition.
6
+ */
7
+ function describeProperty(
8
+ name: string,
9
+ schema: Record<string, unknown>
10
+ ): string {
11
+ const type = schema.type || "any";
12
+ const description = schema.description ? ` — ${schema.description}` : "";
13
+ const isRequired = schema._required ? " (required)" : " (optional)";
14
+ return `\`${name}\` (${type}${isRequired})${description}`;
15
+ }
16
+
17
+ /**
18
+ * Extract property definitions from a JSON Schema object.
19
+ * Handles both flat and nested properties structures.
20
+ */
21
+ function extractProperties(
22
+ inputSchema: Record<string, unknown>
23
+ ): { name: string; schema: Record<string, unknown> }[] {
24
+ const properties = inputSchema.properties as
25
+ | Record<string, Record<string, unknown>>
26
+ | undefined;
27
+ if (!properties) return [];
28
+
29
+ const required =
30
+ (inputSchema.required as string[]) || [];
31
+
32
+ return Object.entries(properties).map(([name, schema]) => ({
33
+ name,
34
+ schema: { ...schema, _required: required.includes(name) },
35
+ }));
36
+ }
37
+
38
+ /**
39
+ * Generate a human-readable type string from a JSON Schema type.
40
+ */
41
+ function schemaType(schema: Record<string, unknown>): string {
42
+ const t = schema.type;
43
+ if (Array.isArray(t)) return t.join(" | ");
44
+ if (typeof t === "string") return t;
45
+ return "any";
46
+ }
47
+
48
+ /**
49
+ * Generate an example value for a property based on its schema type.
50
+ */
51
+ function exampleValue(name: string, schema: Record<string, unknown>): string {
52
+ const t = schemaType(schema);
53
+ if (t === "number" || t === "integer") return "42";
54
+ if (t === "boolean") return "true";
55
+ // For strings, use the property name as a hint
56
+ const lower = name.toLowerCase();
57
+ if (lower.includes("path")) return '"/tmp/example.txt"';
58
+ if (lower.includes("name")) return '"example-name"';
59
+ if (lower.includes("id")) return '"abc-123"';
60
+ if (lower.includes("url") || lower.includes("uri")) return '"https://example.com"';
61
+ if (lower.includes("content") || lower.includes("text")) return '"Hello, world!"';
62
+ if (lower.includes("query") || lower.includes("search")) return '"search terms"';
63
+ return '"value"';
64
+ }
65
+
66
+ /**
67
+ * Generate an example command string for a tool.
68
+ */
69
+ function generateExample(tool: UpstreamTool): string {
70
+ const props = extractProperties(tool.inputSchema);
71
+ if (props.length === 0) return tool.name;
72
+
73
+ const required = props.filter((p) => p.schema._required);
74
+ const args: Record<string, string> = {};
75
+ for (const p of required.slice(0, 3)) {
76
+ args[p.name] = exampleValue(p.name, p.schema);
77
+ }
78
+
79
+ const argsStr = JSON.stringify(args);
80
+ // Keep examples compact
81
+ if (argsStr.length < 80) {
82
+ return `${tool.name} ${argsStr}`;
83
+ }
84
+ return tool.name;
85
+ }
86
+
87
+ /**
88
+ * Generate a complete help text for a tool group.
89
+ */
90
+ export function generateHelpText(group: ToolGroup): string {
91
+ const lines: string[] = [];
92
+
93
+ lines.push(`⚡ MCPico — ${group.groupName}`);
94
+ lines.push("");
95
+ lines.push(
96
+ `Source: ${group.serverName} (${group.tools.length} tool${group.tools.length === 1 ? "" : "s"})`
97
+ );
98
+ lines.push("");
99
+ lines.push("Usage: <subcommand> {" + '"}key{"}": {"}value{"}' + "}");
100
+ lines.push(
101
+ " Send just 'help' to see this reference again."
102
+ );
103
+ lines.push("");
104
+ lines.push("─".repeat(50));
105
+ lines.push("");
106
+
107
+ for (const tool of group.tools) {
108
+ lines.push(` ${tool.name}`);
109
+ if (tool.description) {
110
+ lines.push(` ${tool.description}`);
111
+ }
112
+ lines.push("");
113
+
114
+ const props = extractProperties(tool.inputSchema);
115
+ if (props.length > 0) {
116
+ lines.push(" Parameters:");
117
+ for (const p of props) {
118
+ lines.push(` ${describeProperty(p.name, p.schema)}`);
119
+ }
120
+ lines.push("");
121
+ }
122
+
123
+ const example = generateExample(tool);
124
+ lines.push(` Example: ${example}`);
125
+ lines.push("");
126
+ }
127
+
128
+ lines.push("─".repeat(50));
129
+ lines.push(
130
+ "Generated by MCPico — auto-updates when upstream tools change."
131
+ );
132
+
133
+ return lines.join("\n");
134
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCPico — MCP proxy that bundles flat tool lists into hierarchical subcommand groups.
5
+ *
6
+ * Usage:
7
+ * mcpico [--config <path>]
8
+ *
9
+ * Configuration is read from mcpico.json in the current directory by default.
10
+ */
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { startServer } from "./server.js";
15
+ import type { MCPicoConfig } from "./config.js";
16
+
17
+ async function main(): Promise<void> {
18
+ // Parse CLI args
19
+ const args = process.argv.slice(2);
20
+ let configPath = "mcpico.json";
21
+
22
+ for (let i = 0; i < args.length; i++) {
23
+ if (args[i] === "--config" || args[i] === "-c") {
24
+ configPath = args[i + 1] || configPath;
25
+ i++;
26
+ } else if (args[i] === "--version" || args[i] === "-v") {
27
+ console.log("MCPico v0.1.0");
28
+ process.exit(0);
29
+ } else if (args[i] === "--help" || args[i] === "-h") {
30
+ console.log(`
31
+ MCPico — MCP proxy that bundles flat tool lists into hierarchical subcommand groups.
32
+
33
+ Usage:
34
+ mcpico [--config <path>]
35
+
36
+ Options:
37
+ --config, -c <path> Path to config file (default: mcpico.json)
38
+ --version, -v Show version
39
+ --help, -h Show this help
40
+
41
+ Config file format (mcpico.json):
42
+ {
43
+ "servers": [
44
+ {
45
+ "name": "filesystem",
46
+ "transport": {
47
+ "type": "stdio",
48
+ "command": "npx",
49
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
50
+ }
51
+ }
52
+ ],
53
+ "separator": "_",
54
+ "groups": {}
55
+ }
56
+ `);
57
+ process.exit(0);
58
+ }
59
+ }
60
+
61
+ // Resolve config path
62
+ const resolvedPath = resolve(configPath);
63
+ let config: MCPicoConfig;
64
+
65
+ try {
66
+ const raw = readFileSync(resolvedPath, "utf-8");
67
+ config = JSON.parse(raw) as MCPicoConfig;
68
+ } catch (err) {
69
+ console.error(
70
+ `Error reading config file "${resolvedPath}":`,
71
+ (err as Error).message
72
+ );
73
+ process.exit(1);
74
+ }
75
+
76
+ // Validate
77
+ if (!config.servers || !Array.isArray(config.servers) || config.servers.length === 0) {
78
+ console.error('Config error: "servers" must be a non-empty array');
79
+ process.exit(1);
80
+ }
81
+
82
+ for (const server of config.servers) {
83
+ if (!server.name || !server.transport) {
84
+ console.error(
85
+ 'Config error: each server must have a "name" and "transport"'
86
+ );
87
+ process.exit(1);
88
+ }
89
+ if (
90
+ server.transport.type === "stdio" &&
91
+ !server.transport.command
92
+ ) {
93
+ console.error(
94
+ `Config error: server "${server.name}" missing transport.command`
95
+ );
96
+ process.exit(1);
97
+ }
98
+ }
99
+
100
+ await startServer(config);
101
+ }
102
+
103
+ main().catch((err) => {
104
+ console.error("Fatal error:", err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseCommand } from "./parser.js";
3
+
4
+ describe("parseCommand", () => {
5
+ describe("help / empty", () => {
6
+ it("returns isHelp for empty string", () => {
7
+ const result = parseCommand("");
8
+ expect(result.isHelp).toBe(true);
9
+ expect(result.subcommand).toBeNull();
10
+ expect(result.args).toEqual({});
11
+ expect(result.error).toBeNull();
12
+ });
13
+
14
+ it("returns isHelp for whitespace-only string", () => {
15
+ const result = parseCommand(" ");
16
+ expect(result.isHelp).toBe(true);
17
+ expect(result.error).toBeNull();
18
+ });
19
+
20
+ it("returns isHelp for explicit 'help'", () => {
21
+ const result = parseCommand("help");
22
+ expect(result.isHelp).toBe(true);
23
+ expect(result.subcommand).toBeNull();
24
+ expect(result.args).toEqual({});
25
+ expect(result.error).toBeNull();
26
+ });
27
+
28
+ it("returns isHelp for 'help' with extra whitespace", () => {
29
+ const result = parseCommand(" help ");
30
+ expect(result.isHelp).toBe(true);
31
+ expect(result.args).toEqual({});
32
+ });
33
+ });
34
+
35
+ describe("bare subcommand (no args)", () => {
36
+ it("parses a single-word subcommand", () => {
37
+ const result = parseCommand("read_file");
38
+ expect(result.subcommand).toBe("read_file");
39
+ expect(result.args).toEqual({});
40
+ expect(result.isHelp).toBe(false);
41
+ expect(result.error).toBeNull();
42
+ });
43
+
44
+ it("handles subcommand with trailing whitespace", () => {
45
+ const result = parseCommand("list_dir ");
46
+ expect(result.subcommand).toBe("list_dir");
47
+ expect(result.args).toEqual({});
48
+ });
49
+ });
50
+
51
+ describe("JSON args", () => {
52
+ it("parses simple JSON object args", () => {
53
+ const result = parseCommand('read_file {"path":"/etc/hosts"}');
54
+ expect(result.subcommand).toBe("read_file");
55
+ expect(result.args).toEqual({ path: "/etc/hosts" });
56
+ expect(result.error).toBeNull();
57
+ });
58
+
59
+ it("parses multiple JSON args", () => {
60
+ const result = parseCommand(
61
+ 'write_file {"path":"/tmp/out.txt","content":"hello","encoding":"utf-8"}'
62
+ );
63
+ expect(result.subcommand).toBe("write_file");
64
+ expect(result.args).toEqual({
65
+ path: "/tmp/out.txt",
66
+ content: "hello",
67
+ encoding: "utf-8",
68
+ });
69
+ });
70
+
71
+ it("parses numeric and boolean values", () => {
72
+ const result = parseCommand(
73
+ 'something {"count":42,"enabled":true,"ratio":3.14}'
74
+ );
75
+ expect(result.args).toEqual({
76
+ count: 42,
77
+ enabled: true,
78
+ ratio: 3.14,
79
+ });
80
+ });
81
+
82
+ it("parses nested JSON objects", () => {
83
+ const result = parseCommand(
84
+ 'complex {"nested":{"key":"value"},"list":[1,2,3]}'
85
+ );
86
+ expect(result.args).toEqual({
87
+ nested: { key: "value" },
88
+ list: [1, 2, 3],
89
+ });
90
+ });
91
+
92
+ it("parses null values", () => {
93
+ const result = parseCommand('cmd {"key":null}');
94
+ expect(result.args).toEqual({ key: null });
95
+ });
96
+ });
97
+
98
+ describe("error handling", () => {
99
+ it("returns error for malformed JSON", () => {
100
+ const result = parseCommand('read_file {bad json}');
101
+ expect(result.error).toContain("Could not parse arguments as JSON");
102
+ expect(result.args).toEqual({});
103
+ expect(result.isHelp).toBe(false);
104
+ });
105
+
106
+ it("returns error for JSON array instead of object", () => {
107
+ const result = parseCommand('read_file ["a","b"]');
108
+ expect(result.error).toContain("Arguments must be a JSON object");
109
+ expect(result.args).toEqual({});
110
+ });
111
+
112
+ it("returns error for bare JSON primitive", () => {
113
+ const result = parseCommand('read_file "just a string"');
114
+ expect(result.error).toContain("Arguments must be a JSON object");
115
+ });
116
+
117
+ it("returns error for bare JSON number", () => {
118
+ const result = parseCommand("read_file 42");
119
+ expect(result.error).toContain("Arguments must be a JSON object");
120
+ });
121
+
122
+ it("returns error for incomplete JSON", () => {
123
+ const result = parseCommand('read_file {"path":"/tmp');
124
+ expect(result.error).toContain("Could not parse arguments as JSON");
125
+ });
126
+ });
127
+
128
+ describe("edge cases", () => {
129
+ it("handles subcommand with spaces in name (unusual but valid)", () => {
130
+ // First space splits subcommand from args
131
+ const result = parseCommand('my tool {"a":1}');
132
+ expect(result.subcommand).toBe("my");
133
+ expect(result.args).toEqual({});
134
+ expect(result.error).toContain("Could not parse arguments");
135
+ });
136
+
137
+ it("handles subcommand followed by empty args", () => {
138
+ const result = parseCommand("read_file ");
139
+ expect(result.subcommand).toBe("read_file");
140
+ expect(result.args).toEqual({});
141
+ expect(result.error).toBeNull();
142
+ });
143
+
144
+ it("handles subcommand followed by spaces", () => {
145
+ const result = parseCommand("read_file ");
146
+ expect(result.subcommand).toBe("read_file");
147
+ expect(result.args).toEqual({});
148
+ });
149
+
150
+ it("handles JSON with leading/trailing whitespace in args", () => {
151
+ const result = parseCommand(
152
+ 'read_file {"path":"/tmp"} '
153
+ );
154
+ expect(result.subcommand).toBe("read_file");
155
+ expect(result.args).toEqual({ path: "/tmp" });
156
+ });
157
+
158
+ it("handles empty JSON object", () => {
159
+ const result = parseCommand("cmd {}");
160
+ expect(result.subcommand).toBe("cmd");
161
+ expect(result.args).toEqual({});
162
+ expect(result.error).toBeNull();
163
+ });
164
+
165
+ it("handles subcommand containing underscores", () => {
166
+ const result = parseCommand(
167
+ 'filesystem_read_file {"path":"/tmp"}'
168
+ );
169
+ expect(result.subcommand).toBe("filesystem_read_file");
170
+ expect(result.args).toEqual({ path: "/tmp" });
171
+ });
172
+ });
173
+ });
package/src/parser.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Result of parsing a command string.
3
+ */
4
+ export interface ParsedCommand {
5
+ /** The subcommand name (maps to an upstream tool name) */
6
+ subcommand: string | null;
7
+ /** Parsed arguments to pass to the upstream tool */
8
+ args: Record<string, unknown>;
9
+ /** Whether this is a help request */
10
+ isHelp: boolean;
11
+ /** Error message if parsing failed */
12
+ error: string | null;
13
+ }
14
+
15
+ /**
16
+ * Parse a command string from the model.
17
+ *
18
+ * Format: `<subcommand> <json_args>`
19
+ *
20
+ * - Empty string or "help" → isHelp = true
21
+ * - "read_file" → subcommand = "read_file", args = {}
22
+ * - 'read_file {"path":"/etc/hosts"}' → subcommand = "read_file", args = {path: "/etc/hosts"}
23
+ * - "list_dir" → subcommand = "list_dir", args = {}
24
+ *
25
+ * Error handling:
26
+ * - If JSON is malformed, returns error message
27
+ * - If no subcommand given, returns isHelp = true
28
+ */
29
+ export function parseCommand(rawCommand: string): ParsedCommand {
30
+ const trimmed = rawCommand.trim();
31
+
32
+ // Empty or explicit help
33
+ if (!trimmed || trimmed === "help") {
34
+ return { subcommand: null, args: {}, isHelp: true, error: null };
35
+ }
36
+
37
+ // Find the first space to split subcommand from args
38
+ const spaceIdx = trimmed.indexOf(" ");
39
+
40
+ if (spaceIdx === -1) {
41
+ // No args — just a subcommand
42
+ return {
43
+ subcommand: trimmed,
44
+ args: {},
45
+ isHelp: trimmed === "help",
46
+ error: null,
47
+ };
48
+ }
49
+
50
+ const subcommand = trimmed.slice(0, spaceIdx).trim();
51
+ const argsStr = trimmed.slice(spaceIdx + 1).trim();
52
+
53
+ // Empty args after subcommand
54
+ if (!argsStr) {
55
+ return { subcommand, args: {}, isHelp: false, error: null };
56
+ }
57
+
58
+ // Try JSON parse
59
+ try {
60
+ const args = JSON.parse(argsStr);
61
+ if (typeof args !== "object" || Array.isArray(args)) {
62
+ return {
63
+ subcommand,
64
+ args: {},
65
+ isHelp: false,
66
+ error: "Arguments must be a JSON object, e.g. {\"key\":\"value\"}",
67
+ };
68
+ }
69
+ return { subcommand, args: args as Record<string, unknown>, isHelp: false, error: null };
70
+ } catch {
71
+ return {
72
+ subcommand,
73
+ args: {},
74
+ isHelp: false,
75
+ error: `Could not parse arguments as JSON: "${argsStr}". Use format: <subcommand> {"key":"value",...}`,
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { forwardToolCall } from "./proxy.js";
3
+ import type { DiscoveredServer } from "./discoverer.js";
4
+
5
+ describe("forwardToolCall", () => {
6
+ it("calls client.callTool with the correct tool name and arguments", async () => {
7
+ const callTool = vi.fn().mockResolvedValue({
8
+ content: [{ type: "text", text: "success" }],
9
+ });
10
+
11
+ const server = {
12
+ client: { callTool },
13
+ } as unknown as DiscoveredServer;
14
+
15
+ const result = await forwardToolCall(server, "read_file", {
16
+ path: "/tmp/test.txt",
17
+ });
18
+
19
+ expect(callTool).toHaveBeenCalledWith({
20
+ name: "read_file",
21
+ arguments: { path: "/tmp/test.txt" },
22
+ });
23
+ expect(result).toEqual({
24
+ content: [{ type: "text", text: "success" }],
25
+ });
26
+ });
27
+
28
+ it("passes through errors from the upstream server", async () => {
29
+ const callTool = vi
30
+ .fn()
31
+ .mockRejectedValue(new Error("Upstream connection failed"));
32
+
33
+ const server = {
34
+ client: { callTool },
35
+ } as unknown as DiscoveredServer;
36
+
37
+ await expect(
38
+ forwardToolCall(server, "bad_tool", {})
39
+ ).rejects.toThrow("Upstream connection failed");
40
+ });
41
+
42
+ it("handles empty arguments", async () => {
43
+ const callTool = vi.fn().mockResolvedValue({
44
+ content: [{ type: "text", text: "ok" }],
45
+ });
46
+
47
+ const server = {
48
+ client: { callTool },
49
+ } as unknown as DiscoveredServer;
50
+
51
+ await forwardToolCall(server, "status", {});
52
+ expect(callTool).toHaveBeenCalledWith({
53
+ name: "status",
54
+ arguments: {},
55
+ });
56
+ });
57
+
58
+ it("handles nested arguments", async () => {
59
+ const callTool = vi.fn().mockResolvedValue({
60
+ content: [],
61
+ });
62
+
63
+ const server = {
64
+ client: { callTool },
65
+ } as unknown as DiscoveredServer;
66
+
67
+ await forwardToolCall(server, "complex", {
68
+ nested: { key: "value" },
69
+ array: [1, 2, 3],
70
+ });
71
+
72
+ expect(callTool).toHaveBeenCalledWith({
73
+ name: "complex",
74
+ arguments: { nested: { key: "value" }, array: [1, 2, 3] },
75
+ });
76
+ });
77
+ });
package/src/proxy.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { DiscoveredServer } from "./discoverer.js";
3
+
4
+ /**
5
+ * Forward a tool call to an upstream MCP server and return the result.
6
+ */
7
+ export async function forwardToolCall(
8
+ server: DiscoveredServer,
9
+ toolName: string,
10
+ args: Record<string, unknown>
11
+ ): Promise<CallToolResult> {
12
+ return server.client.callTool({
13
+ name: toolName,
14
+ arguments: args,
15
+ }) as Promise<CallToolResult>;
16
+ }