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
@@ -0,0 +1,148 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { TransportConfig } from "./config.js";
5
+
6
+ /**
7
+ * Tool metadata from an upstream MCP server.
8
+ */
9
+ export interface UpstreamTool {
10
+ name: string;
11
+ description?: string;
12
+ inputSchema: Record<string, unknown>;
13
+ }
14
+
15
+ /**
16
+ * Resource metadata from an upstream MCP server.
17
+ */
18
+ export interface UpstreamResource {
19
+ uri: string;
20
+ name: string;
21
+ description?: string;
22
+ mimeType?: string;
23
+ }
24
+
25
+ /**
26
+ * Prompt metadata from an upstream MCP server.
27
+ */
28
+ export interface UpstreamPrompt {
29
+ name: string;
30
+ description?: string;
31
+ arguments?: Array<{ name: string; description?: string; required?: boolean }>;
32
+ }
33
+
34
+ /**
35
+ * Discovered upstream server with its tools, resources, and prompts.
36
+ */
37
+ export interface DiscoveredServer {
38
+ name: string;
39
+ tools: UpstreamTool[];
40
+ resources: UpstreamResource[];
41
+ prompts: UpstreamPrompt[];
42
+ client: Client;
43
+ }
44
+
45
+ /**
46
+ * Connect to an upstream MCP server and discover its tools.
47
+ *
48
+ * Supports stdio and streamable HTTP transports.
49
+ * Default connection timeout is 30 seconds.
50
+ */
51
+ export async function discoverServer(
52
+ name: string,
53
+ transportConfig: TransportConfig,
54
+ connectTimeoutMs: number = 30_000
55
+ ): Promise<DiscoveredServer> {
56
+ let transport;
57
+
58
+ if (transportConfig.type === "stdio") {
59
+ transport = new StdioClientTransport({
60
+ command: transportConfig.command,
61
+ args: transportConfig.args,
62
+ env: transportConfig.env,
63
+ cwd: transportConfig.cwd,
64
+ });
65
+ } else if (transportConfig.type === "sse") {
66
+ transport = new StreamableHTTPClientTransport(
67
+ new URL(transportConfig.url)
68
+ );
69
+ } else {
70
+ throw new Error(`Unsupported transport type: ${(transportConfig as TransportConfig).type}`);
71
+ }
72
+
73
+ const client = new Client(
74
+ { name: `MCPico-${name}`, version: "0.1.0" },
75
+ { capabilities: {} }
76
+ );
77
+
78
+ // Connect with timeout
79
+ await withTimeout(
80
+ client.connect(transport),
81
+ connectTimeoutMs,
82
+ `Connection to "${name}" timed out after ${connectTimeoutMs}ms`
83
+ );
84
+
85
+ // Discover tools
86
+ const toolsResult = await client.listTools();
87
+ const tools: UpstreamTool[] = (toolsResult.tools || []).map((t) => ({
88
+ name: t.name,
89
+ description: t.description,
90
+ inputSchema: (t.inputSchema as Record<string, unknown>) || {},
91
+ }));
92
+
93
+ // Discover resources (if supported)
94
+ let resources: UpstreamResource[] = [];
95
+ try {
96
+ const resResult = await client.listResources();
97
+ resources = (resResult.resources || []).map((r) => ({
98
+ uri: r.uri,
99
+ name: r.name,
100
+ description: r.description,
101
+ mimeType: r.mimeType,
102
+ }));
103
+ } catch {
104
+ // Resources may not be supported by this server
105
+ }
106
+
107
+ // Discover prompts (if supported)
108
+ let prompts: UpstreamPrompt[] = [];
109
+ try {
110
+ const promptResult = await client.listPrompts();
111
+ prompts = (promptResult.prompts || []).map((p) => ({
112
+ name: p.name,
113
+ description: p.description,
114
+ arguments: (p.arguments || []).map((a) => ({
115
+ name: a.name,
116
+ description: a.description,
117
+ required: a.required,
118
+ })),
119
+ }));
120
+ } catch {
121
+ // Prompts may not be supported by this server
122
+ }
123
+
124
+ return { name, tools, resources, prompts, client };
125
+ }
126
+
127
+ /**
128
+ * Disconnect from an upstream server.
129
+ */
130
+ export async function disconnectServer(server: DiscoveredServer): Promise<void> {
131
+ await server.client.close();
132
+ }
133
+
134
+ /**
135
+ * Race a promise against a timeout.
136
+ */
137
+ async function withTimeout<T>(
138
+ promise: Promise<T>,
139
+ timeoutMs: number,
140
+ errorMessage: string
141
+ ): Promise<T> {
142
+ return Promise.race([
143
+ promise,
144
+ new Promise<T>((_, reject) =>
145
+ setTimeout(() => reject(new Error(errorMessage)), timeoutMs)
146
+ ),
147
+ ]);
148
+ }
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { groupTools, type ToolGroup } from "./grouper.js";
3
+ import type { UpstreamTool } from "./discoverer.js";
4
+
5
+ function makeTool(name: string, description?: string): UpstreamTool {
6
+ return { name, description, inputSchema: {} };
7
+ }
8
+
9
+ function groupNames(groups: ToolGroup[]): string[] {
10
+ return groups.map((g) => g.groupName);
11
+ }
12
+
13
+ function toolNames(group: ToolGroup): string[] {
14
+ return group.tools.map((t) => t.name);
15
+ }
16
+
17
+ describe("groupTools", () => {
18
+ describe("prefix-based grouping", () => {
19
+ it("groups tools by underscore prefix", () => {
20
+ const tools = [
21
+ makeTool("filesystem_read_file"),
22
+ makeTool("filesystem_write_file"),
23
+ makeTool("filesystem_list_dir"),
24
+ ];
25
+ const groups = groupTools("test-server", tools);
26
+
27
+ expect(groups).toHaveLength(1);
28
+ expect(groups[0].groupName).toBe("filesystem");
29
+ expect(toolNames(groups[0])).toEqual([
30
+ "filesystem_read_file",
31
+ "filesystem_write_file",
32
+ "filesystem_list_dir",
33
+ ]);
34
+ });
35
+
36
+ it("groups tools with different prefixes into separate groups", () => {
37
+ const tools = [
38
+ makeTool("fs_read"),
39
+ makeTool("fs_write"),
40
+ makeTool("db_query"),
41
+ makeTool("db_insert"),
42
+ ];
43
+ const groups = groupTools("test-server", tools);
44
+
45
+ expect(groups).toHaveLength(2);
46
+ expect(groupNames(groups)).toEqual(
47
+ expect.arrayContaining(["fs", "db"])
48
+ );
49
+ });
50
+
51
+ it("uses custom separator", () => {
52
+ const tools = [
53
+ makeTool("github.create_issue"),
54
+ makeTool("github.list_repos"),
55
+ ];
56
+ const groups = groupTools("test-server", tools, ".");
57
+
58
+ expect(groups).toHaveLength(1);
59
+ expect(groups[0].groupName).toBe("github");
60
+ });
61
+
62
+ it("puts tools without prefix into server-named group", () => {
63
+ const tools = [
64
+ makeTool("read"),
65
+ makeTool("write"),
66
+ makeTool("filesystem_list"),
67
+ ];
68
+ const groups = groupTools("my-server", tools);
69
+
70
+ const defaultGroup = groups.find(
71
+ (g) => g.groupName === "my-server"
72
+ );
73
+ expect(defaultGroup).toBeDefined();
74
+ expect(toolNames(defaultGroup!)).toEqual(["read", "write"]);
75
+ });
76
+
77
+ it("handles mixed prefixed and unprefixed tools", () => {
78
+ const tools = [
79
+ makeTool("help"),
80
+ makeTool("status"),
81
+ makeTool("fs_read"),
82
+ ];
83
+ const groups = groupTools("test-server", tools);
84
+
85
+ expect(groups).toHaveLength(2);
86
+ const fsGroup = groups.find((g) => g.groupName === "fs");
87
+ const defaultGroup = groups.find(
88
+ (g) => g.groupName === "test-server"
89
+ );
90
+ expect(fsGroup).toBeDefined();
91
+ expect(defaultGroup).toBeDefined();
92
+ expect(toolNames(defaultGroup!)).toEqual(["help", "status"]);
93
+ });
94
+ });
95
+
96
+ describe("explicit overrides", () => {
97
+ it("applies group overrides", () => {
98
+ const tools = [
99
+ makeTool("custom_tool_a"),
100
+ makeTool("custom_tool_b"),
101
+ ];
102
+ const groups = groupTools("test-server", tools, "_", {
103
+ my_group: ["custom_tool_a", "custom_tool_b"],
104
+ });
105
+
106
+ expect(groups).toHaveLength(1);
107
+ expect(groups[0].groupName).toBe("my_group");
108
+ expect(toolNames(groups[0])).toEqual([
109
+ "custom_tool_a",
110
+ "custom_tool_b",
111
+ ]);
112
+ });
113
+
114
+ it("mixes overrides with prefix-based grouping", () => {
115
+ const tools = [
116
+ makeTool("special_tool"),
117
+ makeTool("fs_read"),
118
+ makeTool("fs_write"),
119
+ ];
120
+ const groups = groupTools("test-server", tools, "_", {
121
+ special: ["special_tool"],
122
+ });
123
+
124
+ expect(groups).toHaveLength(2);
125
+ const specialGroup = groups.find((g) => g.groupName === "special");
126
+ const fsGroup = groups.find((g) => g.groupName === "fs");
127
+ expect(specialGroup).toBeDefined();
128
+ expect(fsGroup).toBeDefined();
129
+ expect(toolNames(specialGroup!)).toEqual(["special_tool"]);
130
+ });
131
+
132
+ it("overridden tools are removed from prefix groups", () => {
133
+ // "special_fs_read" would normally go in "special" prefix group,
134
+ // but override puts it in "custom" instead
135
+ const tools = [
136
+ makeTool("special_fs_read"),
137
+ makeTool("special_fs_write"),
138
+ ];
139
+ const groups = groupTools("test-server", tools, "_", {
140
+ custom: ["special_fs_read"],
141
+ });
142
+
143
+ const customGroup = groups.find((g) => g.groupName === "custom");
144
+ const specialGroup = groups.find(
145
+ (g) => g.groupName === "special"
146
+ );
147
+ expect(customGroup).toBeDefined();
148
+ expect(specialGroup).toBeDefined();
149
+ expect(toolNames(customGroup!)).toEqual(["special_fs_read"]);
150
+ expect(toolNames(specialGroup!)).toEqual(["special_fs_write"]);
151
+ });
152
+ });
153
+
154
+ describe("edge cases", () => {
155
+ it("returns empty array for no tools", () => {
156
+ const groups = groupTools("test-server", []);
157
+ expect(groups).toEqual([]);
158
+ });
159
+
160
+ it("handles tools with separator at start (no prefix)", () => {
161
+ const tools = [makeTool("_read")];
162
+ const groups = groupTools("test-server", tools);
163
+ // Prefix index is 0, which is <= 0, so no prefix
164
+ expect(groups).toHaveLength(1);
165
+ expect(groups[0].groupName).toBe("test-server");
166
+ });
167
+
168
+ it("handles tools with separator at end (no suffix)", () => {
169
+ const tools = [makeTool("read_")];
170
+ const groups = groupTools("test-server", tools);
171
+ // Separator index is at last char, so no valid suffix
172
+ expect(groups).toHaveLength(1);
173
+ expect(groups[0].groupName).toBe("test-server");
174
+ });
175
+
176
+ it("sets serverName on all groups", () => {
177
+ const tools = [
178
+ makeTool("fs_read"),
179
+ makeTool("unprefixed"),
180
+ ];
181
+ const groups = groupTools("my-server", tools);
182
+ for (const g of groups) {
183
+ expect(g.serverName).toBe("my-server");
184
+ }
185
+ });
186
+
187
+ it("handles multiple separators in tool name (takes first)", () => {
188
+ const tools = [makeTool("prefix_sub_sub")];
189
+ const groups = groupTools("test-server", tools);
190
+ expect(groups).toHaveLength(1);
191
+ expect(groups[0].groupName).toBe("prefix");
192
+ });
193
+
194
+ it("groups single-tool prefix correctly", () => {
195
+ const tools = [makeTool("solo_tool")];
196
+ const groups = groupTools("test-server", tools);
197
+ expect(groups).toHaveLength(1);
198
+ expect(groups[0].groupName).toBe("solo");
199
+ expect(groups[0].tools).toHaveLength(1);
200
+ });
201
+ });
202
+ });
package/src/grouper.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { GroupOverrides } from "./config.js";
2
+ import type { UpstreamTool } from "./discoverer.js";
3
+
4
+ /**
5
+ * A group of related tools from an upstream server.
6
+ */
7
+ export interface ToolGroup {
8
+ /** Group name (e.g., "filesystem") */
9
+ groupName: string;
10
+ /** Source server name */
11
+ serverName: string;
12
+ /** Tools in this group */
13
+ tools: UpstreamTool[];
14
+ }
15
+
16
+ /**
17
+ * Extract the prefix from a tool name using the given separator.
18
+ * Returns null if no prefix detected.
19
+ */
20
+ function extractPrefix(
21
+ toolName: string,
22
+ separator: string
23
+ ): string | null {
24
+ const idx = toolName.indexOf(separator);
25
+ if (idx <= 0) return null;
26
+ const prefix = toolName.slice(0, idx);
27
+ // Must have a non-empty suffix too
28
+ if (idx >= toolName.length - 1) return null;
29
+ return prefix;
30
+ }
31
+
32
+ /**
33
+ * Group tools from an upstream server by prefix.
34
+ *
35
+ * Strategy:
36
+ * 1. If explicit group overrides exist, apply them first.
37
+ * 2. For remaining tools, extract prefix using separator.
38
+ * 3. Tools without a prefix go into the server's default group.
39
+ */
40
+ export function groupTools(
41
+ serverName: string,
42
+ tools: UpstreamTool[],
43
+ separator: string = "_",
44
+ overrides?: GroupOverrides
45
+ ): ToolGroup[] {
46
+ const groups = new Map<string, UpstreamTool[]>();
47
+ const ungrouped: UpstreamTool[] = [];
48
+
49
+ // Build override lookup: toolName → forcedGroupName
50
+ const overrideMap = new Map<string, string>();
51
+ if (overrides) {
52
+ for (const [group, toolNames] of Object.entries(overrides)) {
53
+ for (const tn of toolNames) {
54
+ overrideMap.set(tn, group);
55
+ }
56
+ }
57
+ }
58
+
59
+ for (const tool of tools) {
60
+ // Check explicit override
61
+ const forcedGroup = overrideMap.get(tool.name);
62
+ if (forcedGroup) {
63
+ const existing = groups.get(forcedGroup) || [];
64
+ existing.push(tool);
65
+ groups.set(forcedGroup, existing);
66
+ continue;
67
+ }
68
+
69
+ // Try prefix extraction
70
+ const prefix = extractPrefix(tool.name, separator);
71
+ if (prefix) {
72
+ const existing = groups.get(prefix) || [];
73
+ existing.push(tool);
74
+ groups.set(prefix, existing);
75
+ } else {
76
+ ungrouped.push(tool);
77
+ }
78
+ }
79
+
80
+ // Tools without a prefix or override go into their own group per server
81
+ const result: ToolGroup[] = [];
82
+ for (const [groupName, groupTools] of groups) {
83
+ result.push({ groupName, serverName, tools: groupTools });
84
+ }
85
+
86
+ if (ungrouped.length > 0) {
87
+ // Put ungrouped tools in a group named after the server
88
+ result.push({
89
+ groupName: serverName,
90
+ serverName,
91
+ tools: ungrouped,
92
+ });
93
+ }
94
+
95
+ return result;
96
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateHelpText } from "./help.js";
3
+ import type { ToolGroup } from "./grouper.js";
4
+ import type { UpstreamTool } from "./discoverer.js";
5
+
6
+ function makeTool(overrides: Partial<UpstreamTool> = {}): UpstreamTool {
7
+ return {
8
+ name: "test_tool",
9
+ description: "A test tool",
10
+ inputSchema: {
11
+ type: "object",
12
+ properties: {
13
+ param1: { type: "string", description: "First parameter" },
14
+ },
15
+ required: ["param1"],
16
+ },
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function makeGroup(overrides: Partial<ToolGroup> = {}): ToolGroup {
22
+ return {
23
+ groupName: "test",
24
+ serverName: "test-server",
25
+ tools: [makeTool()],
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("generateHelpText", () => {
31
+ it("includes group name in header", () => {
32
+ const text = generateHelpText(makeGroup({ groupName: "filesystem" }));
33
+ expect(text).toContain("MCPico — filesystem");
34
+ });
35
+
36
+ it("includes server name", () => {
37
+ const text = generateHelpText(
38
+ makeGroup({ serverName: "my-server" })
39
+ );
40
+ expect(text).toContain("my-server");
41
+ });
42
+
43
+ it("shows correct tool count", () => {
44
+ const text = generateHelpText(makeGroup());
45
+ expect(text).toContain("1 tool");
46
+ });
47
+
48
+ it("shows plural for multiple tools", () => {
49
+ const group = makeGroup({
50
+ tools: [makeTool({ name: "tool_a" }), makeTool({ name: "tool_b" })],
51
+ });
52
+ const text = generateHelpText(group);
53
+ expect(text).toContain("2 tools");
54
+ });
55
+
56
+ it("includes usage instructions", () => {
57
+ const text = generateHelpText(makeGroup());
58
+ expect(text).toContain("Usage:");
59
+ expect(text).toContain("<subcommand>");
60
+ expect(text).toContain("Send just 'help'");
61
+ });
62
+
63
+ it("lists each tool by name", () => {
64
+ const group = makeGroup({
65
+ tools: [
66
+ makeTool({ name: "read_file", description: "Read a file" }),
67
+ makeTool({ name: "write_file", description: "Write a file" }),
68
+ ],
69
+ });
70
+ const text = generateHelpText(group);
71
+ expect(text).toContain("read_file");
72
+ expect(text).toContain("Read a file");
73
+ expect(text).toContain("write_file");
74
+ expect(text).toContain("Write a file");
75
+ });
76
+
77
+ it("shows tool description", () => {
78
+ const text = generateHelpText(
79
+ makeGroup({
80
+ tools: [makeTool({ description: "Does something useful" })],
81
+ })
82
+ );
83
+ expect(text).toContain("Does something useful");
84
+ });
85
+
86
+ it("shows parameters section for tools with properties", () => {
87
+ const text = generateHelpText(makeGroup());
88
+ expect(text).toContain("Parameters:");
89
+ expect(text).toContain("param1");
90
+ expect(text).toContain("(string");
91
+ expect(text).toContain("(required)");
92
+ expect(text).toContain("First parameter");
93
+ });
94
+
95
+ it("marks optional parameters", () => {
96
+ const tool = makeTool({
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: {
100
+ optional_param: { type: "string", description: "Not required" },
101
+ },
102
+ required: [],
103
+ },
104
+ });
105
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
106
+ expect(text).toContain("(optional)");
107
+ });
108
+
109
+ it("does not show Parameters section for tools without properties", () => {
110
+ const tool = makeTool({
111
+ inputSchema: { type: "object", properties: {} },
112
+ });
113
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
114
+ expect(text).not.toContain("Parameters:");
115
+ });
116
+
117
+ it("generates examples for tools with parameters", () => {
118
+ const tool = makeTool({
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ path: { type: "string", description: "File path" },
123
+ },
124
+ required: ["path"],
125
+ },
126
+ });
127
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
128
+ expect(text).toContain("Example:");
129
+ expect(text).toContain("/tmp/example.txt");
130
+ });
131
+
132
+ it("shows bare subcommand for parameterless tools", () => {
133
+ const tool = makeTool({
134
+ inputSchema: { type: "object", properties: {} },
135
+ });
136
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
137
+ expect(text).toContain("Example:");
138
+ expect(text).toContain(tool.name);
139
+ });
140
+
141
+ it("includes footer", () => {
142
+ const text = generateHelpText(makeGroup());
143
+ expect(text).toContain("Generated by MCPico");
144
+ });
145
+
146
+ it("handles tools without descriptions", () => {
147
+ const tool = makeTool({ description: undefined });
148
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
149
+ expect(text).toContain(tool.name);
150
+ // Should not crash — tool is still listed
151
+ });
152
+
153
+ it("generates smart example values for known parameter names", () => {
154
+ const tool = makeTool({
155
+ name: "search",
156
+ inputSchema: {
157
+ type: "object",
158
+ properties: {
159
+ query: { type: "string", description: "Search query" },
160
+ limit: { type: "integer", description: "Max results" },
161
+ url: { type: "string", description: "Target URL" },
162
+ },
163
+ required: ["query", "limit", "url"],
164
+ },
165
+ });
166
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
167
+ expect(text).toContain("search terms");
168
+ expect(text).toContain("42");
169
+ expect(text).toContain("https://example.com");
170
+ });
171
+
172
+ it("handles tools with a mix of required and optional params in example", () => {
173
+ const tool = makeTool({
174
+ name: "write",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ path: { type: "string" },
179
+ content: { type: "string" },
180
+ encoding: { type: "string" },
181
+ },
182
+ required: ["path"],
183
+ },
184
+ });
185
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
186
+ // Example should show the required param
187
+ expect(text).toContain("path");
188
+ expect(text).toContain("/tmp/example.txt");
189
+ });
190
+
191
+ it("handles tools with no inputSchema", () => {
192
+ const tool = makeTool({ inputSchema: {} });
193
+ const text = generateHelpText(makeGroup({ tools: [tool] }));
194
+ expect(text).toContain(tool.name);
195
+ // Should not crash
196
+ });
197
+ });