@sammorrowdrums/mcpi-ext 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 (213) hide show
  1. package/.dockerignore +6 -0
  2. package/.github/dependabot.yml +25 -0
  3. package/.github/workflows/ci.yml +50 -0
  4. package/.hadolint.yaml +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +7 -0
  7. package/AGENTS.md +118 -0
  8. package/DECISIONS.md +99 -0
  9. package/Dockerfile +39 -0
  10. package/README.md +278 -0
  11. package/aube-lock.yaml +4146 -0
  12. package/dist/code-mode/code-mode-integration.test.d.ts +2 -0
  13. package/dist/code-mode/code-mode-integration.test.d.ts.map +1 -0
  14. package/dist/code-mode/code-mode-integration.test.js +116 -0
  15. package/dist/code-mode/code-mode-integration.test.js.map +1 -0
  16. package/dist/code-mode/eligibility.d.ts +10 -0
  17. package/dist/code-mode/eligibility.d.ts.map +1 -0
  18. package/dist/code-mode/eligibility.js +14 -0
  19. package/dist/code-mode/eligibility.js.map +1 -0
  20. package/dist/code-mode/eligibility.test.d.ts +2 -0
  21. package/dist/code-mode/eligibility.test.d.ts.map +1 -0
  22. package/dist/code-mode/eligibility.test.js +53 -0
  23. package/dist/code-mode/eligibility.test.js.map +1 -0
  24. package/dist/code-mode/executor.d.ts +35 -0
  25. package/dist/code-mode/executor.d.ts.map +1 -0
  26. package/dist/code-mode/executor.js +120 -0
  27. package/dist/code-mode/executor.js.map +1 -0
  28. package/dist/code-mode/executor.test.d.ts +2 -0
  29. package/dist/code-mode/executor.test.d.ts.map +1 -0
  30. package/dist/code-mode/executor.test.js +130 -0
  31. package/dist/code-mode/executor.test.js.map +1 -0
  32. package/dist/code-mode/index.d.ts +65 -0
  33. package/dist/code-mode/index.d.ts.map +1 -0
  34. package/dist/code-mode/index.js +175 -0
  35. package/dist/code-mode/index.js.map +1 -0
  36. package/dist/code-mode/tools.d.ts +45 -0
  37. package/dist/code-mode/tools.d.ts.map +1 -0
  38. package/dist/code-mode/tools.js +70 -0
  39. package/dist/code-mode/tools.js.map +1 -0
  40. package/dist/code-mode/type-hints.d.ts +20 -0
  41. package/dist/code-mode/type-hints.d.ts.map +1 -0
  42. package/dist/code-mode/type-hints.js +189 -0
  43. package/dist/code-mode/type-hints.js.map +1 -0
  44. package/dist/code-mode/type-hints.test.d.ts +2 -0
  45. package/dist/code-mode/type-hints.test.d.ts.map +1 -0
  46. package/dist/code-mode/type-hints.test.js +157 -0
  47. package/dist/code-mode/type-hints.test.js.map +1 -0
  48. package/dist/docker-e2e.d.ts +55 -0
  49. package/dist/docker-e2e.d.ts.map +1 -0
  50. package/dist/docker-e2e.js +235 -0
  51. package/dist/docker-e2e.js.map +1 -0
  52. package/dist/docker-e2e.test.d.ts +2 -0
  53. package/dist/docker-e2e.test.d.ts.map +1 -0
  54. package/dist/docker-e2e.test.js +176 -0
  55. package/dist/docker-e2e.test.js.map +1 -0
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +128 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.test.d.ts +2 -0
  61. package/dist/index.test.d.ts.map +1 -0
  62. package/dist/index.test.js +8 -0
  63. package/dist/index.test.js.map +1 -0
  64. package/dist/mcp/client-manager.d.ts +46 -0
  65. package/dist/mcp/client-manager.d.ts.map +1 -0
  66. package/dist/mcp/client-manager.js +148 -0
  67. package/dist/mcp/client-manager.js.map +1 -0
  68. package/dist/mcp/client-manager.test.d.ts +2 -0
  69. package/dist/mcp/client-manager.test.d.ts.map +1 -0
  70. package/dist/mcp/client-manager.test.js +204 -0
  71. package/dist/mcp/client-manager.test.js.map +1 -0
  72. package/dist/mcp/config-loader.d.ts +8 -0
  73. package/dist/mcp/config-loader.d.ts.map +1 -0
  74. package/dist/mcp/config-loader.js +43 -0
  75. package/dist/mcp/config-loader.js.map +1 -0
  76. package/dist/mcp/config-loader.test.d.ts +2 -0
  77. package/dist/mcp/config-loader.test.d.ts.map +1 -0
  78. package/dist/mcp/config-loader.test.js +93 -0
  79. package/dist/mcp/config-loader.test.js.map +1 -0
  80. package/dist/mcp/config.d.ts +46 -0
  81. package/dist/mcp/config.d.ts.map +1 -0
  82. package/dist/mcp/config.js +28 -0
  83. package/dist/mcp/config.js.map +1 -0
  84. package/dist/mcp/index.d.ts +5 -0
  85. package/dist/mcp/index.d.ts.map +1 -0
  86. package/dist/mcp/index.js +4 -0
  87. package/dist/mcp/index.js.map +1 -0
  88. package/dist/skills/discover.d.ts +10 -0
  89. package/dist/skills/discover.d.ts.map +1 -0
  90. package/dist/skills/discover.js +60 -0
  91. package/dist/skills/discover.js.map +1 -0
  92. package/dist/skills/format.d.ts +10 -0
  93. package/dist/skills/format.d.ts.map +1 -0
  94. package/dist/skills/format.js +38 -0
  95. package/dist/skills/format.js.map +1 -0
  96. package/dist/skills/format.test.d.ts +2 -0
  97. package/dist/skills/format.test.d.ts.map +1 -0
  98. package/dist/skills/format.test.js +63 -0
  99. package/dist/skills/format.test.js.map +1 -0
  100. package/dist/skills/index.d.ts +6 -0
  101. package/dist/skills/index.d.ts.map +1 -0
  102. package/dist/skills/index.js +6 -0
  103. package/dist/skills/index.js.map +1 -0
  104. package/dist/skills/load-skill-tool.d.ts +40 -0
  105. package/dist/skills/load-skill-tool.d.ts.map +1 -0
  106. package/dist/skills/load-skill-tool.js +104 -0
  107. package/dist/skills/load-skill-tool.js.map +1 -0
  108. package/dist/skills/mcp-tool-proxy.d.ts +11 -0
  109. package/dist/skills/mcp-tool-proxy.d.ts.map +1 -0
  110. package/dist/skills/mcp-tool-proxy.js +116 -0
  111. package/dist/skills/mcp-tool-proxy.js.map +1 -0
  112. package/dist/skills/skill-integration.test.d.ts +2 -0
  113. package/dist/skills/skill-integration.test.d.ts.map +1 -0
  114. package/dist/skills/skill-integration.test.js +119 -0
  115. package/dist/skills/skill-integration.test.js.map +1 -0
  116. package/dist/skills/skill-registry.d.ts +39 -0
  117. package/dist/skills/skill-registry.d.ts.map +1 -0
  118. package/dist/skills/skill-registry.js +48 -0
  119. package/dist/skills/skill-registry.js.map +1 -0
  120. package/dist/skills/skill-registry.test.d.ts +2 -0
  121. package/dist/skills/skill-registry.test.d.ts.map +1 -0
  122. package/dist/skills/skill-registry.test.js +67 -0
  123. package/dist/skills/skill-registry.test.js.map +1 -0
  124. package/dist/test-servers/weather-server.d.ts +12 -0
  125. package/dist/test-servers/weather-server.d.ts.map +1 -0
  126. package/dist/test-servers/weather-server.js +139 -0
  127. package/dist/test-servers/weather-server.js.map +1 -0
  128. package/dist/test-servers/weather-stdio.d.ts +2 -0
  129. package/dist/test-servers/weather-stdio.d.ts.map +1 -0
  130. package/dist/test-servers/weather-stdio.js +11 -0
  131. package/dist/test-servers/weather-stdio.js.map +1 -0
  132. package/dist/tool-cli/cli.d.ts +3 -0
  133. package/dist/tool-cli/cli.d.ts.map +1 -0
  134. package/dist/tool-cli/cli.js +179 -0
  135. package/dist/tool-cli/cli.js.map +1 -0
  136. package/dist/tool-cli/constants.d.ts +7 -0
  137. package/dist/tool-cli/constants.d.ts.map +1 -0
  138. package/dist/tool-cli/constants.js +15 -0
  139. package/dist/tool-cli/constants.js.map +1 -0
  140. package/dist/tool-cli/format.d.ts +8 -0
  141. package/dist/tool-cli/format.d.ts.map +1 -0
  142. package/dist/tool-cli/format.js +57 -0
  143. package/dist/tool-cli/format.js.map +1 -0
  144. package/dist/tool-cli/format.test.d.ts +2 -0
  145. package/dist/tool-cli/format.test.d.ts.map +1 -0
  146. package/dist/tool-cli/format.test.js +30 -0
  147. package/dist/tool-cli/format.test.js.map +1 -0
  148. package/dist/tool-cli/index.d.ts +4 -0
  149. package/dist/tool-cli/index.d.ts.map +1 -0
  150. package/dist/tool-cli/index.js +4 -0
  151. package/dist/tool-cli/index.js.map +1 -0
  152. package/dist/tool-cli/rpc-client.d.ts +6 -0
  153. package/dist/tool-cli/rpc-client.d.ts.map +1 -0
  154. package/dist/tool-cli/rpc-client.js +25 -0
  155. package/dist/tool-cli/rpc-client.js.map +1 -0
  156. package/dist/tool-cli/rpc-server.d.ts +48 -0
  157. package/dist/tool-cli/rpc-server.d.ts.map +1 -0
  158. package/dist/tool-cli/rpc-server.js +220 -0
  159. package/dist/tool-cli/rpc-server.js.map +1 -0
  160. package/dist/tool-cli/rpc-server.test.d.ts +2 -0
  161. package/dist/tool-cli/rpc-server.test.d.ts.map +1 -0
  162. package/dist/tool-cli/rpc-server.test.js +154 -0
  163. package/dist/tool-cli/rpc-server.test.js.map +1 -0
  164. package/eslint.config.js +27 -0
  165. package/images/banner.webp +0 -0
  166. package/images/code-c-maude.webp +0 -0
  167. package/images/nuclear-mcp-football.webp +0 -0
  168. package/images/the-skill-dealer.webp +0 -0
  169. package/mise.toml +65 -0
  170. package/package.json +46 -0
  171. package/scripts/docker-entrypoint.sh +29 -0
  172. package/scripts/test-echo-server.mjs +28 -0
  173. package/scripts/test-mcp-integration.mjs +59 -0
  174. package/scripts/test-tool-cli-smoke.mjs +62 -0
  175. package/src/code-mode/code-mode-integration.test.ts +135 -0
  176. package/src/code-mode/eligibility.test.ts +60 -0
  177. package/src/code-mode/eligibility.ts +16 -0
  178. package/src/code-mode/executor.test.ts +168 -0
  179. package/src/code-mode/executor.ts +163 -0
  180. package/src/code-mode/index.ts +208 -0
  181. package/src/code-mode/tools.ts +110 -0
  182. package/src/code-mode/type-hints.test.ts +187 -0
  183. package/src/code-mode/type-hints.ts +249 -0
  184. package/src/docker-e2e.test.ts +195 -0
  185. package/src/docker-e2e.ts +277 -0
  186. package/src/index.test.ts +8 -0
  187. package/src/index.ts +158 -0
  188. package/src/mcp/client-manager.test.ts +250 -0
  189. package/src/mcp/client-manager.ts +199 -0
  190. package/src/mcp/config-loader.test.ts +115 -0
  191. package/src/mcp/config-loader.ts +46 -0
  192. package/src/mcp/config.ts +40 -0
  193. package/src/mcp/index.ts +4 -0
  194. package/src/skills/discover.ts +81 -0
  195. package/src/skills/format.test.ts +70 -0
  196. package/src/skills/format.ts +42 -0
  197. package/src/skills/index.ts +9 -0
  198. package/src/skills/load-skill-tool.ts +136 -0
  199. package/src/skills/mcp-tool-proxy.ts +143 -0
  200. package/src/skills/skill-integration.test.ts +139 -0
  201. package/src/skills/skill-registry.test.ts +83 -0
  202. package/src/skills/skill-registry.ts +69 -0
  203. package/src/test-servers/weather-server.ts +171 -0
  204. package/src/test-servers/weather-stdio.ts +11 -0
  205. package/src/tool-cli/cli.ts +232 -0
  206. package/src/tool-cli/constants.ts +15 -0
  207. package/src/tool-cli/format.test.ts +33 -0
  208. package/src/tool-cli/format.ts +56 -0
  209. package/src/tool-cli/index.ts +3 -0
  210. package/src/tool-cli/rpc-client.ts +39 -0
  211. package/src/tool-cli/rpc-server.test.ts +193 -0
  212. package/src/tool-cli/rpc-server.ts +287 -0
  213. package/tsconfig.json +21 -0
@@ -0,0 +1,15 @@
1
+ /** Default port for the tool-cli JSON-RPC server. */
2
+ export const DEFAULT_PORT = 7179;
3
+
4
+ /** Environment variable to override the default port. */
5
+ export const PORT_ENV_VAR = "TOOL_CLI_PORT";
6
+
7
+ /** Resolve the port from environment or default. */
8
+ export function resolvePort(): number {
9
+ const envPort = process.env[PORT_ENV_VAR];
10
+ if (envPort) {
11
+ const parsed = parseInt(envPort, 10);
12
+ if (!isNaN(parsed) && parsed > 0 && parsed < 65536) return parsed;
13
+ }
14
+ return DEFAULT_PORT;
15
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatToolCliForPrompt } from "./format.js";
3
+
4
+ describe("formatToolCliForPrompt", () => {
5
+ it("returns empty string when no servers connected", () => {
6
+ expect(formatToolCliForPrompt(0)).toBe("");
7
+ });
8
+
9
+ it("includes tool-cli usage instructions when servers are connected", () => {
10
+ const result = formatToolCliForPrompt(2);
11
+ expect(result).toContain("tool-cli");
12
+ expect(result).toContain("<tool_cli>");
13
+ expect(result).toContain("</tool_cli>");
14
+ expect(result).toContain("--help");
15
+ expect(result).toContain("<server>");
16
+ expect(result).toContain("<tool>");
17
+ expect(result).toContain("2 MCP server(s) currently connected");
18
+ });
19
+
20
+ it("advises preferring skills over tool-cli", () => {
21
+ const result = formatToolCliForPrompt(1);
22
+ expect(result).toContain("prefer the skill");
23
+ });
24
+
25
+ it("includes shell chaining and piping examples", () => {
26
+ const result = formatToolCliForPrompt(1);
27
+ expect(result).toContain("grep");
28
+ expect(result).toContain("|");
29
+ expect(result).toContain("jq");
30
+ expect(result).toContain("xargs");
31
+ expect(result).toContain("Prefer piping and chaining");
32
+ });
33
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Format system prompt section advising the agent when and how to use tool-cli.
3
+ *
4
+ * Only included when MCP servers are connected. Tells the agent about
5
+ * progressive discovery via tool-cli as an alternative to skill-based access.
6
+ */
7
+ export function formatToolCliForPrompt(serverCount: number): string {
8
+ if (serverCount === 0) return "";
9
+
10
+ return `
11
+
12
+ <tool_cli>
13
+ You have access to \`tool-cli\`, a CLI for discovering and calling MCP server tools progressively.
14
+
15
+ Use tool-cli when:
16
+ - No skill covers the task you need to do
17
+ - You want to explore what tools are available on a server
18
+ - You need ad-hoc access to an MCP tool without loading a full skill
19
+
20
+ If a skill exists for the task, prefer the skill — it provides workflow instructions and curated tool access.
21
+
22
+ Discovery (progressive — only fetch what you need):
23
+ tool-cli --help # List MCP servers with tool counts
24
+ tool-cli <server> # List all tools on a server
25
+ tool-cli <server> <tool> # Show full schema for a tool
26
+
27
+ Calling tools:
28
+ tool-cli <server> <tool> '{"key":"value"}' # Call a tool with JSON arguments
29
+ tool-cli <server> <tool> '{}' --out /tmp/result.json # Save large output to file
30
+
31
+ tool-cli outputs plain text or JSON. When a tool provides structured output (typed JSON),
32
+ tool-cli returns it directly as JSON — use \`jq\` to query fields.
33
+ Chain calls, filter, and transform results using pipes and bash idioms:
34
+
35
+ # Search across tool results
36
+ tool-cli myserver search_docs '{"query":"auth"}' | grep -i "token"
37
+
38
+ # Query structured JSON output with jq
39
+ tool-cli myserver list_issues '{"repo":"owner/repo"}' | jq '.[].title'
40
+
41
+ # Chain tool calls — feed one result into another
42
+ tool-cli myserver list_items '{}' | jq -r '.[0].id' | xargs -I{} tool-cli myserver get_item '{"id":"{}"}'
43
+
44
+ # Process multiple items
45
+ for city in London Tokyo Paris; do
46
+ echo "=== $city ==="; tool-cli weather check_weather '{"city":"'$city'"}';
47
+ done
48
+
49
+ # Combine with standard tools
50
+ tool-cli myserver export_csv '{"table":"users"}' | sort -t, -k2 | head -20
51
+
52
+ Prefer piping and chaining over multiple separate tool calls when processing collections or filtering results.
53
+ Errors go to stderr with exit code 1 — use \`&&\` or \`set -e\` for safe chaining.
54
+ ${serverCount} MCP server(s) currently connected.
55
+ </tool_cli>`;
56
+ }
@@ -0,0 +1,3 @@
1
+ export { ToolCliRpcServer } from "./rpc-server.js";
2
+ export { formatToolCliForPrompt } from "./format.js";
3
+ export { DEFAULT_PORT, PORT_ENV_VAR, resolvePort } from "./constants.js";
@@ -0,0 +1,39 @@
1
+ import { resolvePort } from "./constants.js";
2
+
3
+ interface JsonRpcResponse {
4
+ jsonrpc: "2.0";
5
+ result?: unknown;
6
+ error?: { code: number; message: string; data?: unknown };
7
+ id: number | string | null;
8
+ }
9
+
10
+ /**
11
+ * Send a JSON-RPC 2.0 request to the tool-cli RPC server.
12
+ * Throws on network errors and JSON-RPC error responses.
13
+ */
14
+ export async function rpcCall(
15
+ method: string,
16
+ params: Record<string, unknown> = {},
17
+ ): Promise<unknown> {
18
+ const port = resolvePort();
19
+ const url = `http://127.0.0.1:${port}`;
20
+
21
+ const response = await fetch(url, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({
25
+ jsonrpc: "2.0",
26
+ method,
27
+ params,
28
+ id: 1,
29
+ }),
30
+ });
31
+
32
+ const json = (await response.json()) as JsonRpcResponse;
33
+
34
+ if (json.error) {
35
+ throw new Error(json.error.message);
36
+ }
37
+
38
+ return json.result;
39
+ }
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { ToolCliRpcServer } from "./rpc-server.js";
3
+ import type { McpClientManager } from "../mcp/index.js";
4
+
5
+ /** Minimal mock of McpClientManager for RPC server testing. */
6
+ function createMockManager(): McpClientManager {
7
+ const tools = [
8
+ {
9
+ name: "get_weather",
10
+ description: "Get current weather for a city",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: { city: { type: "string", description: "City name" } },
14
+ required: ["city"],
15
+ },
16
+ annotations: { readOnlyHint: true },
17
+ serverName: "weather",
18
+ },
19
+ {
20
+ name: "set_alarm",
21
+ description: "Set a weather alarm",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ city: { type: "string" },
26
+ threshold: { type: "number" },
27
+ },
28
+ required: ["city", "threshold"],
29
+ },
30
+ annotations: { readOnlyHint: false, destructiveHint: false },
31
+ serverName: "weather",
32
+ },
33
+ {
34
+ name: "search_docs",
35
+ description: "Search documentation",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: { query: { type: "string", description: "Search query" } },
39
+ required: ["query"],
40
+ },
41
+ outputSchema: {
42
+ type: "object",
43
+ properties: { results: { type: "array" } },
44
+ },
45
+ annotations: { readOnlyHint: true },
46
+ serverName: "docs",
47
+ },
48
+ ];
49
+
50
+ return {
51
+ getConnectedServers: () => ["weather", "docs"],
52
+ getToolsForServer: (name: string) => tools.filter((t) => t.serverName === name),
53
+ getTools: () => tools,
54
+ getClient: (name: string) => {
55
+ if (name === "weather" || name === "docs") {
56
+ return {
57
+ callTool: async (params: { name: string; arguments?: Record<string, unknown> }) => ({
58
+ content: [{ type: "text", text: `Called ${params.name}` }],
59
+ }),
60
+ };
61
+ }
62
+ return undefined;
63
+ },
64
+ } as unknown as McpClientManager;
65
+ }
66
+
67
+ // Use port 0 so the OS assigns a free port — avoids collisions when src + dist tests run together
68
+ describe("ToolCliRpcServer", () => {
69
+ let server: ToolCliRpcServer;
70
+ let port: number;
71
+
72
+ beforeAll(async () => {
73
+ const manager = createMockManager();
74
+ server = new ToolCliRpcServer(manager, 0);
75
+ await server.start();
76
+ port = server.getPort();
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await server.stop();
81
+ });
82
+
83
+ async function rpc(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
84
+ const res = await fetch(`http://127.0.0.1:${port}`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }),
88
+ });
89
+ return res.json();
90
+ }
91
+
92
+ it("lists connected servers with tool counts", async () => {
93
+ const resp = (await rpc("listServers")) as {
94
+ result: { servers: { name: string; toolCount: number; examples: string[] }[] };
95
+ };
96
+ const { servers } = resp.result;
97
+ expect(servers).toHaveLength(2);
98
+
99
+ const weather = servers.find((s) => s.name === "weather");
100
+ expect(weather).toBeDefined();
101
+ expect(weather?.toolCount).toBe(2);
102
+ expect(weather?.examples.length).toBeGreaterThan(0);
103
+
104
+ const docs = servers.find((s) => s.name === "docs");
105
+ expect(docs).toBeDefined();
106
+ expect(docs?.toolCount).toBe(1);
107
+ });
108
+
109
+ it("lists tools for a specific server", async () => {
110
+ const resp = (await rpc("listTools", { server: "weather" })) as {
111
+ result: { server: string; tools: { name: string; description: string }[] };
112
+ };
113
+ expect(resp.result.server).toBe("weather");
114
+ expect(resp.result.tools).toHaveLength(2);
115
+ expect(resp.result.tools[0].name).toBe("get_weather");
116
+ expect(resp.result.tools[1].name).toBe("set_alarm");
117
+ });
118
+
119
+ it("describes a tool with full schema", async () => {
120
+ const resp = (await rpc("describeTool", { server: "weather", tool: "get_weather" })) as {
121
+ result: {
122
+ name: string;
123
+ description: string;
124
+ inputSchema: Record<string, unknown>;
125
+ annotations: Record<string, unknown>;
126
+ };
127
+ };
128
+ expect(resp.result.name).toBe("get_weather");
129
+ expect(resp.result.description).toBe("Get current weather for a city");
130
+ expect(resp.result.inputSchema).toHaveProperty("properties");
131
+ expect(resp.result.annotations).toEqual({ readOnlyHint: true });
132
+ });
133
+
134
+ it("calls a tool and returns result", async () => {
135
+ const resp = (await rpc("callTool", {
136
+ server: "weather",
137
+ tool: "get_weather",
138
+ arguments: { city: "London" },
139
+ })) as {
140
+ result: { content: { type: string; text: string }[] };
141
+ };
142
+ expect(resp.result.content).toHaveLength(1);
143
+ expect(resp.result.content[0].text).toBe("Called get_weather");
144
+ });
145
+
146
+ it("returns error for unknown server", async () => {
147
+ const resp = (await rpc("listTools", { server: "nonexistent" })) as {
148
+ error: { code: number; message: string };
149
+ };
150
+ expect(resp.error).toBeDefined();
151
+ expect(resp.error.code).toBe(-32602);
152
+ expect(resp.error.message).toContain("not found");
153
+ });
154
+
155
+ it("returns error for unknown tool", async () => {
156
+ const resp = (await rpc("describeTool", { server: "weather", tool: "nonexistent" })) as {
157
+ error: { code: number; message: string };
158
+ };
159
+ expect(resp.error).toBeDefined();
160
+ expect(resp.error.message).toContain("not found");
161
+ });
162
+
163
+ it("returns error for unknown method", async () => {
164
+ const resp = (await rpc("unknownMethod")) as {
165
+ error: { code: number; message: string };
166
+ };
167
+ expect(resp.error).toBeDefined();
168
+ expect(resp.error.code).toBe(-32601);
169
+ });
170
+
171
+ it("returns error for missing required params", async () => {
172
+ const resp = (await rpc("listTools", {})) as {
173
+ error: { code: number; message: string };
174
+ };
175
+ expect(resp.error).toBeDefined();
176
+ expect(resp.error.code).toBe(-32602);
177
+ });
178
+
179
+ it("rejects non-POST requests", async () => {
180
+ const res = await fetch(`http://127.0.0.1:${port}`, { method: "GET" });
181
+ expect(res.status).toBe(405);
182
+ });
183
+
184
+ it("handles malformed JSON gracefully", async () => {
185
+ const res = await fetch(`http://127.0.0.1:${port}`, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: "not json at all",
189
+ });
190
+ const json = (await res.json()) as { error: { code: number } };
191
+ expect(json.error.code).toBe(-32700);
192
+ });
193
+ });
@@ -0,0 +1,287 @@
1
+ import http from "node:http";
2
+ import type { McpClientManager, McpTool } from "../mcp/index.js";
3
+ import { resolvePort } from "./constants.js";
4
+
5
+ interface JsonRpcRequest {
6
+ jsonrpc: "2.0";
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ id: number | string | null;
10
+ }
11
+
12
+ interface JsonRpcResponse {
13
+ jsonrpc: "2.0";
14
+ result?: unknown;
15
+ error?: { code: number; message: string; data?: unknown };
16
+ id: number | string | null;
17
+ }
18
+
19
+ interface ServerInfo {
20
+ name: string;
21
+ toolCount: number;
22
+ examples: string[];
23
+ }
24
+
25
+ interface ToolSummary {
26
+ name: string;
27
+ description: string;
28
+ hasStructuredOutput: boolean;
29
+ }
30
+
31
+ interface ToolDetails {
32
+ name: string;
33
+ description: string;
34
+ inputSchema: Record<string, unknown>;
35
+ outputSchema?: Record<string, unknown>;
36
+ annotations?: Record<string, unknown>;
37
+ }
38
+
39
+ export type { ServerInfo, ToolSummary, ToolDetails };
40
+
41
+ /**
42
+ * JSON-RPC 2.0 server that exposes MCP tool discovery and execution.
43
+ *
44
+ * Methods:
45
+ * - listServers → server names + tool counts + random examples
46
+ * - listTools(server) → tool names + descriptions
47
+ * - describeTool(server, tool) → full schema
48
+ * - callTool(server, tool, arguments) → execute and return result
49
+ */
50
+ export class ToolCliRpcServer {
51
+ private server: http.Server | null = null;
52
+ private port: number;
53
+
54
+ constructor(
55
+ private mcpManager: McpClientManager,
56
+ port?: number,
57
+ ) {
58
+ this.port = port ?? resolvePort();
59
+ }
60
+
61
+ /** Start the HTTP server. Resolves once listening. */
62
+ async start(log?: (msg: string) => void): Promise<void> {
63
+ if (this.server) return;
64
+
65
+ const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
66
+
67
+ const server = http.createServer((req, res) => {
68
+ if (req.method !== "POST") {
69
+ res.writeHead(405, { "Content-Type": "application/json" });
70
+ res.end(JSON.stringify(rpcError(null, -32600, "Only POST accepted")));
71
+ return;
72
+ }
73
+
74
+ let body = "";
75
+ let aborted = false;
76
+
77
+ req.on("error", () => {
78
+ aborted = true;
79
+ });
80
+
81
+ req.on("data", (chunk: Buffer) => {
82
+ if (aborted) return;
83
+ body += chunk.toString();
84
+ if (body.length > MAX_BODY_SIZE) {
85
+ aborted = true;
86
+ res.writeHead(413, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify(rpcError(null, -32600, "Request too large")));
88
+ req.destroy();
89
+ }
90
+ });
91
+
92
+ req.on("end", () => {
93
+ if (aborted) return;
94
+ void this.handleBody(body)
95
+ .then((response) => {
96
+ res.writeHead(200, { "Content-Type": "application/json" });
97
+ res.end(JSON.stringify(response));
98
+ })
99
+ .catch(() => {
100
+ try {
101
+ res.writeHead(500, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify(rpcError(null, -32603, "Internal error")));
103
+ } catch {
104
+ res.end();
105
+ }
106
+ });
107
+ });
108
+ });
109
+
110
+ return new Promise<void>((resolve, reject) => {
111
+ server.once("error", (err) => {
112
+ this.server = null;
113
+ reject(err);
114
+ });
115
+ server.listen(this.port, "127.0.0.1", () => {
116
+ this.server = server;
117
+ log?.(`[tool-cli] RPC server listening on 127.0.0.1:${this.port}`);
118
+ resolve();
119
+ });
120
+ });
121
+ }
122
+
123
+ /** Stop the HTTP server. */
124
+ async stop(): Promise<void> {
125
+ const server = this.server;
126
+ if (!server) return;
127
+ this.server = null;
128
+
129
+ return new Promise<void>((resolve) => {
130
+ server.close(() => resolve());
131
+ });
132
+ }
133
+
134
+ /** Returns the port the server is listening on (resolved after start for port 0). */
135
+ getPort(): number {
136
+ if (this.server) {
137
+ const addr = this.server.address();
138
+ if (addr && typeof addr === "object") return addr.port;
139
+ }
140
+ return this.port;
141
+ }
142
+
143
+ private async handleBody(body: string): Promise<JsonRpcResponse> {
144
+ let parsed: JsonRpcRequest;
145
+ try {
146
+ parsed = JSON.parse(body) as JsonRpcRequest;
147
+ } catch {
148
+ return rpcError(null, -32700, "Parse error");
149
+ }
150
+
151
+ if (parsed.jsonrpc !== "2.0" || !parsed.method) {
152
+ return rpcError(parsed.id ?? null, -32600, "Invalid Request");
153
+ }
154
+
155
+ try {
156
+ const result = await this.dispatch(parsed.method, parsed.params ?? {});
157
+ return { jsonrpc: "2.0", result, id: parsed.id ?? null };
158
+ } catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ const code = err instanceof RpcMethodError ? err.code : -32603;
161
+ return rpcError(parsed.id ?? null, code, message);
162
+ }
163
+ }
164
+
165
+ private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
166
+ switch (method) {
167
+ case "listServers":
168
+ return this.listServers();
169
+ case "listTools":
170
+ return this.listTools(params);
171
+ case "describeTool":
172
+ return this.describeTool(params);
173
+ case "callTool":
174
+ return this.callTool(params);
175
+ default:
176
+ throw new RpcMethodError(-32601, `Method not found: ${method}`);
177
+ }
178
+ }
179
+
180
+ private listServers(): { servers: ServerInfo[] } {
181
+ const serverNames = this.mcpManager.getConnectedServers();
182
+ const servers: ServerInfo[] = serverNames.map((name) => {
183
+ const tools = this.mcpManager.getToolsForServer(name);
184
+ return {
185
+ name,
186
+ toolCount: tools.length,
187
+ examples: pickRandomExamples(tools, 3),
188
+ };
189
+ });
190
+ return { servers };
191
+ }
192
+
193
+ private listTools(params: Record<string, unknown>): { server: string; tools: ToolSummary[] } {
194
+ const server = requireString(params, "server");
195
+ this.assertServerExists(server);
196
+ const tools = this.mcpManager.getToolsForServer(server);
197
+ return {
198
+ server,
199
+ tools: tools.map((t) => ({
200
+ name: t.name,
201
+ description: t.description ?? "(no description)",
202
+ hasStructuredOutput: t.outputSchema != null,
203
+ })),
204
+ };
205
+ }
206
+
207
+ private describeTool(params: Record<string, unknown>): ToolDetails {
208
+ const server = requireString(params, "server");
209
+ const toolName = requireString(params, "tool");
210
+ this.assertServerExists(server);
211
+
212
+ const tool = this.mcpManager.getToolsForServer(server).find((t) => t.name === toolName);
213
+ if (!tool) {
214
+ throw new RpcMethodError(-32602, `Tool "${toolName}" not found on server "${server}"`);
215
+ }
216
+
217
+ return {
218
+ name: tool.name,
219
+ description: tool.description ?? "(no description)",
220
+ inputSchema: tool.inputSchema,
221
+ outputSchema: tool.outputSchema,
222
+ annotations: tool.annotations,
223
+ };
224
+ }
225
+
226
+ private async callTool(params: Record<string, unknown>): Promise<{
227
+ content: unknown[];
228
+ isError?: boolean;
229
+ structuredContent?: Record<string, unknown>;
230
+ }> {
231
+ const server = requireString(params, "server");
232
+ const toolName = requireString(params, "tool");
233
+ this.assertServerExists(server);
234
+
235
+ const client = this.mcpManager.getClient(server);
236
+ if (!client) {
237
+ throw new RpcMethodError(-32603, `No client for server "${server}"`);
238
+ }
239
+
240
+ const toolArgs = (params.arguments as Record<string, unknown>) ?? {};
241
+
242
+ const result = await client.callTool({ name: toolName, arguments: toolArgs });
243
+ return {
244
+ content: result.content as unknown[],
245
+ isError: result.isError === true ? true : undefined,
246
+ structuredContent: result.structuredContent as Record<string, unknown> | undefined,
247
+ };
248
+ }
249
+
250
+ private assertServerExists(server: string): void {
251
+ if (!this.mcpManager.getConnectedServers().includes(server)) {
252
+ throw new RpcMethodError(
253
+ -32602,
254
+ `Server "${server}" not found. Connected: ${this.mcpManager.getConnectedServers().join(", ") || "(none)"}`,
255
+ );
256
+ }
257
+ }
258
+ }
259
+
260
+ class RpcMethodError extends Error {
261
+ constructor(
262
+ public code: number,
263
+ message: string,
264
+ ) {
265
+ super(message);
266
+ this.name = "RpcMethodError";
267
+ }
268
+ }
269
+
270
+ function rpcError(id: number | string | null, code: number, message: string): JsonRpcResponse {
271
+ return { jsonrpc: "2.0", error: { code, message }, id };
272
+ }
273
+
274
+ function requireString(params: Record<string, unknown>, key: string): string {
275
+ const val = params[key];
276
+ if (typeof val !== "string" || val.length === 0) {
277
+ throw new RpcMethodError(-32602, `Missing or invalid parameter: "${key}" (string required)`);
278
+ }
279
+ return val;
280
+ }
281
+
282
+ /** Pick up to `n` random tool names from a list. */
283
+ function pickRandomExamples(tools: McpTool[], n: number): string[] {
284
+ if (tools.length <= n) return tools.map((t) => t.name);
285
+ const shuffled = [...tools].sort(() => Math.random() - 0.5);
286
+ return shuffled.slice(0, n).map((t) => t.name);
287
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "resolveJsonModule": true,
17
+ "types": ["node"]
18
+ },
19
+ "include": ["src"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }