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.
- package/README.md +172 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +32 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +87 -0
- package/dist/config.test.js.map +1 -0
- package/dist/discoverer.d.ts +52 -0
- package/dist/discoverer.js +84 -0
- package/dist/discoverer.js.map +1 -0
- package/dist/discoverer.test.d.ts +1 -0
- package/dist/discoverer.test.js +178 -0
- package/dist/discoverer.test.js.map +1 -0
- package/dist/grouper.d.ts +22 -0
- package/dist/grouper.js +70 -0
- package/dist/grouper.js.map +1 -0
- package/dist/grouper.test.d.ts +1 -0
- package/dist/grouper.test.js +169 -0
- package/dist/grouper.test.js.map +1 -0
- package/dist/help.d.ts +5 -0
- package/dist/help.js +115 -0
- package/dist/help.js.map +1 -0
- package/dist/help.test.d.ts +1 -0
- package/dist/help.test.js +173 -0
- package/dist/help.test.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +90 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +28 -0
- package/dist/parser.js +60 -0
- package/dist/parser.js.map +1 -0
- package/dist/parser.test.d.ts +1 -0
- package/dist/parser.test.js +142 -0
- package/dist/parser.test.js.map +1 -0
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +10 -0
- package/dist/proxy.js.map +1 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +61 -0
- package/dist/proxy.test.js.map +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +212 -0
- package/dist/server.js.map +1 -0
- package/mcplico.example.json +18 -0
- package/package.json +36 -0
- package/src/config.test.ts +96 -0
- package/src/config.ts +76 -0
- package/src/discoverer.test.ts +222 -0
- package/src/discoverer.ts +148 -0
- package/src/grouper.test.ts +202 -0
- package/src/grouper.ts +96 -0
- package/src/help.test.ts +197 -0
- package/src/help.ts +134 -0
- package/src/index.ts +106 -0
- package/src/parser.test.ts +173 -0
- package/src/parser.ts +78 -0
- package/src/proxy.test.ts +77 -0
- package/src/proxy.ts +16 -0
- package/src/server.ts +299 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|