@moxxy/plugin-mcp 0.26.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/dist/admin/config-io.d.ts +46 -0
  3. package/dist/admin/config-io.d.ts.map +1 -0
  4. package/dist/admin/config-io.js +108 -0
  5. package/dist/admin/config-io.js.map +1 -0
  6. package/dist/admin/config-schema.d.ts +65 -0
  7. package/dist/admin/config-schema.d.ts.map +1 -0
  8. package/dist/admin/config-schema.js +36 -0
  9. package/dist/admin/config-schema.js.map +1 -0
  10. package/dist/admin/index.d.ts +21 -0
  11. package/dist/admin/index.d.ts.map +1 -0
  12. package/dist/admin/index.js +189 -0
  13. package/dist/admin/index.js.map +1 -0
  14. package/dist/admin/runtime.d.ts +25 -0
  15. package/dist/admin/runtime.d.ts.map +1 -0
  16. package/dist/admin/runtime.js +196 -0
  17. package/dist/admin/runtime.js.map +1 -0
  18. package/dist/admin/schema.d.ts +37 -0
  19. package/dist/admin/schema.d.ts.map +1 -0
  20. package/dist/admin/schema.js +97 -0
  21. package/dist/admin/schema.js.map +1 -0
  22. package/dist/admin/secrets.d.ts +24 -0
  23. package/dist/admin/secrets.d.ts.map +1 -0
  24. package/dist/admin/secrets.js +35 -0
  25. package/dist/admin/secrets.js.map +1 -0
  26. package/dist/admin/skill.d.ts +18 -0
  27. package/dist/admin/skill.d.ts.map +1 -0
  28. package/dist/admin/skill.js +70 -0
  29. package/dist/admin/skill.js.map +1 -0
  30. package/dist/admin/tools/add.d.ts +19 -0
  31. package/dist/admin/tools/add.d.ts.map +1 -0
  32. package/dist/admin/tools/add.js +97 -0
  33. package/dist/admin/tools/add.js.map +1 -0
  34. package/dist/admin/tools/list.d.ts +3 -0
  35. package/dist/admin/tools/list.d.ts.map +1 -0
  36. package/dist/admin/tools/list.js +16 -0
  37. package/dist/admin/tools/list.js.map +1 -0
  38. package/dist/admin/tools/remove.d.ts +6 -0
  39. package/dist/admin/tools/remove.d.ts.map +1 -0
  40. package/dist/admin/tools/remove.js +41 -0
  41. package/dist/admin/tools/remove.js.map +1 -0
  42. package/dist/admin/tools/test.d.ts +8 -0
  43. package/dist/admin/tools/test.d.ts.map +1 -0
  44. package/dist/admin/tools/test.js +73 -0
  45. package/dist/admin/tools/test.js.map +1 -0
  46. package/dist/admin/types.d.ts +107 -0
  47. package/dist/admin/types.d.ts.map +1 -0
  48. package/dist/admin/types.js +2 -0
  49. package/dist/admin/types.js.map +1 -0
  50. package/dist/admin.d.ts +7 -0
  51. package/dist/admin.d.ts.map +1 -0
  52. package/dist/admin.js +7 -0
  53. package/dist/admin.js.map +1 -0
  54. package/dist/client.d.ts +10 -0
  55. package/dist/client.d.ts.map +1 -0
  56. package/dist/client.js +73 -0
  57. package/dist/client.js.map +1 -0
  58. package/dist/index.d.ts +17 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +50 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/timeout.d.ts +23 -0
  63. package/dist/timeout.d.ts.map +1 -0
  64. package/dist/timeout.js +35 -0
  65. package/dist/timeout.js.map +1 -0
  66. package/dist/types.d.ts +59 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +4 -0
  69. package/dist/types.js.map +1 -0
  70. package/dist/wrap.d.ts +23 -0
  71. package/dist/wrap.d.ts.map +1 -0
  72. package/dist/wrap.js +193 -0
  73. package/dist/wrap.js.map +1 -0
  74. package/package.json +63 -0
  75. package/src/admin/config-io.test.ts +188 -0
  76. package/src/admin/config-io.ts +112 -0
  77. package/src/admin/config-schema.ts +39 -0
  78. package/src/admin/index.ts +225 -0
  79. package/src/admin/runtime.test.ts +470 -0
  80. package/src/admin/runtime.ts +244 -0
  81. package/src/admin/schema.test.ts +70 -0
  82. package/src/admin/schema.ts +110 -0
  83. package/src/admin/secrets.test.ts +60 -0
  84. package/src/admin/secrets.ts +55 -0
  85. package/src/admin/skill.test.ts +143 -0
  86. package/src/admin/skill.ts +83 -0
  87. package/src/admin/tools/add.test.ts +193 -0
  88. package/src/admin/tools/add.ts +118 -0
  89. package/src/admin/tools/list.ts +19 -0
  90. package/src/admin/tools/remove.test.ts +74 -0
  91. package/src/admin/tools/remove.ts +46 -0
  92. package/src/admin/tools/test.test.ts +123 -0
  93. package/src/admin/tools/test.ts +84 -0
  94. package/src/admin/types.ts +112 -0
  95. package/src/admin.ts +25 -0
  96. package/src/client.ts +120 -0
  97. package/src/discovery.test.ts +45 -0
  98. package/src/index.test.ts +95 -0
  99. package/src/index.ts +101 -0
  100. package/src/timeout.ts +37 -0
  101. package/src/types.ts +57 -0
  102. package/src/wrap.test.ts +261 -0
  103. package/src/wrap.ts +236 -0
@@ -0,0 +1,261 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { wrapMcpServerTools, wrapMcpServerToolsLazy } from './wrap.js';
3
+ import type { McpClientLike, McpToolDescriptor } from './types.js';
4
+ import { asSessionId, asToolCallId, asTurnId } from '@moxxy/sdk';
5
+
6
+ const baseCtx = () => ({
7
+ sessionId: asSessionId('s'),
8
+ turnId: asTurnId('t'),
9
+ callId: asToolCallId('c'),
10
+ cwd: '/tmp',
11
+ signal: new AbortController().signal,
12
+ log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
13
+ logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
14
+ });
15
+
16
+ const makeFakeClient = (): McpClientLike & { calls: Array<{ name: string; arguments: unknown }> } => {
17
+ const calls: Array<{ name: string; arguments: unknown }> = [];
18
+ return {
19
+ calls,
20
+ async listTools() {
21
+ return {
22
+ tools: [
23
+ {
24
+ name: 'fetch',
25
+ description: 'Fetch a URL',
26
+ inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
27
+ },
28
+ {
29
+ name: 'shell',
30
+ description: undefined,
31
+ inputSchema: { type: 'object' },
32
+ },
33
+ ],
34
+ };
35
+ },
36
+ async callTool(args) {
37
+ calls.push(args);
38
+ return {
39
+ content: [{ type: 'text', text: `called ${args.name}` }],
40
+ isError: false,
41
+ };
42
+ },
43
+ async close() {},
44
+ };
45
+ };
46
+
47
+ describe('wrapMcpServerTools', () => {
48
+ it('wraps each MCP tool with the default prefix and preserves JSON schema', async () => {
49
+ const client = makeFakeClient();
50
+ const tools = await wrapMcpServerTools({
51
+ server: { name: 'demo', command: 'noop' },
52
+ client,
53
+ });
54
+ expect(tools).toHaveLength(2);
55
+ expect(tools[0]!.name).toBe('mcp__demo__fetch');
56
+ expect(tools[0]!.description).toBe('Fetch a URL');
57
+ expect(tools[0]!.inputJsonSchema).toEqual({
58
+ type: 'object',
59
+ properties: { url: { type: 'string' } },
60
+ required: ['url'],
61
+ });
62
+ expect(tools[1]!.name).toBe('mcp__demo__shell');
63
+ expect(tools[1]!.description).toContain('shell');
64
+ });
65
+
66
+ it('routes tool invocations through callTool and stringifies the content', async () => {
67
+ const client = makeFakeClient();
68
+ const tools = await wrapMcpServerTools({
69
+ server: { name: 'demo', command: 'noop' },
70
+ client,
71
+ });
72
+ const result = await tools[0]!.handler({ url: 'https://x' }, baseCtx());
73
+ expect(result).toBe('called fetch');
74
+ expect(client.calls).toEqual([{ name: 'fetch', arguments: { url: 'https://x' } }]);
75
+ });
76
+
77
+ it('formats isError results with [error] prefix', async () => {
78
+ const client = makeFakeClient();
79
+ vi.spyOn(client, 'callTool').mockResolvedValueOnce({
80
+ content: [{ type: 'text', text: 'no permission' }],
81
+ isError: true,
82
+ });
83
+ const tools = await wrapMcpServerTools({
84
+ server: { name: 'demo', command: 'noop' },
85
+ client,
86
+ });
87
+ const result = await tools[0]!.handler({ url: 'https://x' }, baseCtx());
88
+ expect(result).toBe('[error] no permission');
89
+ });
90
+
91
+ it('passes through resource inline text instead of a bare [resource] (u86-3)', async () => {
92
+ const client = makeFakeClient();
93
+ vi.spyOn(client, 'callTool').mockResolvedValueOnce({
94
+ content: [{ type: 'resource', resource: { uri: 'file:///a.txt', mimeType: 'text/plain', text: 'hello body' } }],
95
+ isError: false,
96
+ } as never);
97
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
98
+ const result = await tools[0]!.handler({ url: 'x' }, baseCtx());
99
+ expect(result).toBe('hello body');
100
+ });
101
+
102
+ it('annotates a binary resource with uri/mimeType rather than swallowing it (u86-3)', async () => {
103
+ const client = makeFakeClient();
104
+ vi.spyOn(client, 'callTool').mockResolvedValueOnce({
105
+ content: [{ type: 'resource', resource: { uri: 'file:///a.bin', mimeType: 'application/octet-stream', blob: 'AAAA' } }],
106
+ isError: false,
107
+ } as never);
108
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
109
+ const result = await tools[0]!.handler({ url: 'x' }, baseCtx());
110
+ expect(result).toBe('[resource:file:///a.bin application/octet-stream]');
111
+ });
112
+
113
+ it('still placeholders an image block', async () => {
114
+ const client = makeFakeClient();
115
+ vi.spyOn(client, 'callTool').mockResolvedValueOnce({
116
+ content: [{ type: 'image', data: 'AAAA', mimeType: 'image/png' }],
117
+ isError: false,
118
+ } as never);
119
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
120
+ const result = await tools[0]!.handler({ url: 'x' }, baseCtx());
121
+ expect(result).toBe('[image:image/png]');
122
+ });
123
+
124
+ it('honors a custom tool-name prefix', async () => {
125
+ const client = makeFakeClient();
126
+ const tools = await wrapMcpServerTools({
127
+ server: { name: 'demo', command: 'noop' },
128
+ client,
129
+ toolNamePrefix: (s, t) => `x_${s}_${t}`,
130
+ });
131
+ expect(tools[0]!.name).toBe('x_demo_fetch');
132
+ });
133
+
134
+ it('aborts when ctx.signal is fired', async () => {
135
+ const client = makeFakeClient();
136
+ const tools = await wrapMcpServerTools({
137
+ server: { name: 'demo', command: 'noop' },
138
+ client,
139
+ });
140
+ const controller = new AbortController();
141
+ const ctx = { ...baseCtx(), signal: controller.signal };
142
+ controller.abort();
143
+ await expect(tools[0]!.handler({ url: 'x' }, ctx)).rejects.toThrow(/aborted/);
144
+ });
145
+
146
+ it('rejects a missing required field WITHOUT calling the server', async () => {
147
+ const client = makeFakeClient();
148
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
149
+ // `fetch` declares required: ['url']; emit it missing.
150
+ const result = await tools[0]!.handler({}, baseCtx());
151
+ expect(result).toMatch(/invalid arguments.*missing required field "url"/);
152
+ expect(client.calls).toHaveLength(0);
153
+ });
154
+
155
+ it('rejects a wrong primitive type WITHOUT calling the server', async () => {
156
+ const client = makeFakeClient();
157
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
158
+ const result = await tools[0]!.handler({ url: 123 }, baseCtx());
159
+ expect(result).toMatch(/field "url" must be of type string/);
160
+ expect(client.calls).toHaveLength(0);
161
+ });
162
+
163
+ it('forwards a well-formed call that satisfies the declared schema', async () => {
164
+ const client = makeFakeClient();
165
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
166
+ const result = await tools[0]!.handler({ url: 'https://ok' }, baseCtx());
167
+ expect(result).toBe('called fetch');
168
+ expect(client.calls).toEqual([{ name: 'fetch', arguments: { url: 'https://ok' } }]);
169
+ });
170
+
171
+ it('does not reject when the server declares no usable schema', async () => {
172
+ const client = makeFakeClient();
173
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
174
+ // `shell` (tools[1]) declares only { type: 'object' } — anything passes.
175
+ const result = await tools[1]!.handler({ anything: ['goes'] }, baseCtx());
176
+ expect(result).toBe('called shell');
177
+ });
178
+ });
179
+
180
+ describe('runMcpCallWithFallback (timeout + settle-once)', () => {
181
+ afterEach(() => {
182
+ vi.useRealTimers();
183
+ });
184
+
185
+ it('rejects with a timeout error when callTool never resolves', async () => {
186
+ vi.useFakeTimers();
187
+ const client = makeFakeClient();
188
+ // callTool hangs forever.
189
+ vi.spyOn(client, 'callTool').mockImplementation(() => new Promise<never>(() => {}));
190
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
191
+
192
+ const promise = tools[0]!.handler({ url: 'x' }, baseCtx());
193
+ // Attach the rejection assertion before advancing so the rejection is observed.
194
+ const assertion = expect(promise).rejects.toThrow(/timed out/);
195
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
196
+ await assertion;
197
+ });
198
+
199
+ it('does not double-settle when callTool resolves after an abort', async () => {
200
+ const client = makeFakeClient();
201
+ let resolveCall: (v: { content: Array<{ type: 'text'; text: string }>; isError: boolean }) => void = () => {};
202
+ vi.spyOn(client, 'callTool').mockImplementation(
203
+ () =>
204
+ new Promise((res) => {
205
+ resolveCall = res;
206
+ }),
207
+ );
208
+ const tools = await wrapMcpServerTools({ server: { name: 'demo', command: 'noop' }, client });
209
+ const controller = new AbortController();
210
+ const ctx = { ...baseCtx(), signal: controller.signal };
211
+
212
+ const promise = tools[0]!.handler({ url: 'x' }, ctx);
213
+ controller.abort();
214
+ // The late resolution must NOT win or throw "already settled".
215
+ resolveCall({ content: [{ type: 'text', text: 'too late' }], isError: false });
216
+ await expect(promise).rejects.toThrow(/aborted/);
217
+ });
218
+ });
219
+
220
+ describe('wrapMcpServerToolsLazy', () => {
221
+ const descriptors: ReadonlyArray<McpToolDescriptor> = [
222
+ { name: 'fetch', description: 'Fetch a URL', inputSchema: { type: 'object' } },
223
+ ];
224
+
225
+ it('connects lazily on first call and caches the client for the second', async () => {
226
+ const client = makeFakeClient();
227
+ const getClient = vi.fn(async () => client);
228
+ const tools = wrapMcpServerToolsLazy({
229
+ server: { name: 'demo', command: 'noop' },
230
+ descriptors,
231
+ getClient,
232
+ });
233
+ expect(getClient).not.toHaveBeenCalled(); // building does not connect
234
+
235
+ await tools[0]!.handler({ url: 'a' }, baseCtx());
236
+ await tools[0]!.handler({ url: 'b' }, baseCtx());
237
+ // Two invocations, but the lazy wrapper hands the same factory each time.
238
+ // The connection caching itself is the factory's job; here we assert the
239
+ // factory is invoked per call and the calls reach the client.
240
+ expect(getClient).toHaveBeenCalledTimes(2);
241
+ expect(client.calls).toEqual([
242
+ { name: 'fetch', arguments: { url: 'a' } },
243
+ { name: 'fetch', arguments: { url: 'b' } },
244
+ ]);
245
+ });
246
+
247
+ it('throws on a pre-aborted signal without invoking getClient', async () => {
248
+ const getClient = vi.fn(async () => makeFakeClient());
249
+ const tools = wrapMcpServerToolsLazy({
250
+ server: { name: 'demo', command: 'noop' },
251
+ descriptors,
252
+ getClient,
253
+ });
254
+ const controller = new AbortController();
255
+ controller.abort();
256
+ await expect(
257
+ tools[0]!.handler({ url: 'x' }, { ...baseCtx(), signal: controller.signal }),
258
+ ).rejects.toThrow(/aborted/);
259
+ expect(getClient).not.toHaveBeenCalled();
260
+ });
261
+ });
package/src/wrap.ts ADDED
@@ -0,0 +1,236 @@
1
+ import { z } from 'zod';
2
+ import { defineTool, type ToolDef } from '@moxxy/sdk';
3
+ import {
4
+ defaultToolNamePrefix,
5
+ type McpClientLike,
6
+ type McpContentBlock,
7
+ type McpServerConfig,
8
+ type McpToolDescriptor,
9
+ } from './types.js';
10
+
11
+ /**
12
+ * Hard cap on a single MCP tool call. The MCP SDK's `callTool` doesn't
13
+ * accept an AbortSignal, so without a timeout a hung server (crashed
14
+ * stdio child, dead websocket, blocked DB query) would hang the agent's
15
+ * tool-use loop indefinitely — leaving a permanent pending dot in the UI
16
+ * with no way to recover without killing moxxy. 5 minutes is enough room
17
+ * for slow operations (image generation, large queries) but bounded.
18
+ */
19
+ const MCP_CALL_TIMEOUT_MS = 5 * 60 * 1000;
20
+
21
+ /**
22
+ * Race the MCP call against (1) abort and (2) a hard timeout. Whichever
23
+ * settles first wins. If the underlying callTool ever does resolve after
24
+ * we've rejected, its result is silently discarded — the MCP SDK's
25
+ * cleanup is the SDK's problem.
26
+ */
27
+ async function runMcpCallWithFallback<T>(
28
+ callPromise: Promise<T>,
29
+ signal: AbortSignal,
30
+ toolName: string,
31
+ ): Promise<T> {
32
+ return await new Promise<T>((resolve, reject) => {
33
+ let settled = false;
34
+ const settle = (fn: () => void): void => {
35
+ if (settled) return;
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ signal.removeEventListener('abort', onAbort);
39
+ fn();
40
+ };
41
+ const onAbort = (): void => {
42
+ settle(() => reject(new Error(`aborted MCP tool "${toolName}"`)));
43
+ };
44
+ const timer = setTimeout(() => {
45
+ settle(() =>
46
+ reject(new Error(`MCP tool "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS}ms`)),
47
+ );
48
+ }, MCP_CALL_TIMEOUT_MS);
49
+ signal.addEventListener('abort', onAbort, { once: true });
50
+ callPromise.then(
51
+ (v) => settle(() => resolve(v)),
52
+ (err: unknown) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))),
53
+ );
54
+ });
55
+ }
56
+
57
+ export interface WrapOptions {
58
+ readonly server: McpServerConfig;
59
+ readonly client: McpClientLike;
60
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
61
+ }
62
+
63
+ export async function wrapMcpServerTools(opts: WrapOptions): Promise<ToolDef[]> {
64
+ const prefix = opts.toolNamePrefix ?? defaultToolNamePrefix;
65
+ const list = await opts.client.listTools();
66
+ // Eager path: the connection is already open, so the resolver is a trivial
67
+ // thunk over the live client.
68
+ const resolveClient = (): Promise<McpClientLike> => Promise.resolve(opts.client);
69
+ return list.tools.map((descriptor) =>
70
+ wrapOneMcpTool(descriptor, opts.server.name, resolveClient, prefix),
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Build ToolDefs from CACHED descriptors without an open client. The
76
+ * provided `getClient` factory is invoked the first time any tool runs;
77
+ * the promise is cached so subsequent calls reuse the same connection.
78
+ * Enables instant TUI boot — connections only happen when the model
79
+ * actually invokes a tool from a given MCP server.
80
+ */
81
+ export interface WrapLazyOptions {
82
+ readonly server: McpServerConfig;
83
+ readonly descriptors: ReadonlyArray<McpToolDescriptor>;
84
+ readonly getClient: () => Promise<McpClientLike>;
85
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
86
+ }
87
+
88
+ export function wrapMcpServerToolsLazy(opts: WrapLazyOptions): ToolDef[] {
89
+ const prefix = opts.toolNamePrefix ?? defaultToolNamePrefix;
90
+ return opts.descriptors.map((descriptor) =>
91
+ wrapOneMcpTool(descriptor, opts.server.name, opts.getClient, prefix),
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Single tool builder shared by the eager and lazy paths. They differ only in
97
+ * how the client is obtained: the eager path passes a thunk over a live client,
98
+ * the lazy path passes a `getClient` factory that connects on first call (and
99
+ * caches the connection for subsequent calls). Everything else — name, schema,
100
+ * permission, abort checks, the timeout/abort race, and result rendering — is
101
+ * identical, so it lives here once.
102
+ */
103
+ function wrapOneMcpTool(
104
+ descriptor: McpToolDescriptor,
105
+ serverName: string,
106
+ resolveClient: () => Promise<McpClientLike>,
107
+ prefix: (s: string, t: string) => string,
108
+ ): ToolDef {
109
+ const wrappedName = prefix(serverName, descriptor.name);
110
+ return defineTool({
111
+ name: wrappedName,
112
+ description: descriptor.description ?? `MCP tool ${descriptor.name} on server ${serverName}`,
113
+ inputSchema: z.record(z.string(), z.unknown()),
114
+ inputJsonSchema: descriptor.inputSchema ?? { type: 'object' },
115
+ permission: { action: 'prompt' },
116
+ handler: async (input, ctx) => {
117
+ if (ctx.signal.aborted) throw new Error('aborted');
118
+ // The runtime Zod schema is a permissive record (so OpenAI accepts it),
119
+ // so whatever the model emits would otherwise reach the server verbatim.
120
+ // Fail fast with a readable message on an obviously-malformed call
121
+ // (missing required field / wrong primitive type) instead of forwarding
122
+ // garbage that may crash or hang the server (hitting the 5-min timeout).
123
+ const violation = validateAgainstSchema(input, descriptor.inputSchema);
124
+ if (violation) return `[error] invalid arguments for ${descriptor.name}: ${violation}`;
125
+ // For the lazy path this pays the network/spawn cost only on first call
126
+ // (the factory caches its connection); for the eager path it resolves
127
+ // immediately to the already-open client.
128
+ const client = await resolveClient();
129
+ if (ctx.signal.aborted) throw new Error('aborted');
130
+ const result = await runMcpCallWithFallback(
131
+ client.callTool({ name: descriptor.name, arguments: input }),
132
+ ctx.signal,
133
+ wrappedName,
134
+ );
135
+ return renderResult(result.content, result.isError);
136
+ },
137
+ });
138
+ }
139
+
140
+ function renderResult(content: ReadonlyArray<McpContentBlock> | undefined, isError?: boolean): string {
141
+ const parts: string[] = [];
142
+ for (const block of content ?? []) {
143
+ if (block.type === 'text') parts.push(block.text);
144
+ // Images still can't be surfaced through this string-returning handler
145
+ // (rich image passthrough would need the document/image ContentBlock
146
+ // path); keep the placeholder so the model at least knows one exists.
147
+ else if (block.type === 'image') parts.push(`[image:${block.mimeType}]`);
148
+ else if (block.type === 'resource') parts.push(renderResource(block.resource));
149
+ }
150
+ const text = parts.join('\n');
151
+ return isError ? `[error] ${text}` : text;
152
+ }
153
+
154
+ /**
155
+ * Surface an MCP resource block. A text resource (`resource.text`) is the
156
+ * whole point — passing it through stops the model getting a bare
157
+ * `[resource]` for content it could actually read. A binary/blob resource
158
+ * has no text representation here, so we annotate the placeholder with its
159
+ * uri/mimeType rather than swallow it silently.
160
+ */
161
+ function renderResource(resource: unknown): string {
162
+ if (resource && typeof resource === 'object') {
163
+ const r = resource as { uri?: unknown; mimeType?: unknown; text?: unknown };
164
+ if (typeof r.text === 'string') return r.text;
165
+ const meta = [
166
+ typeof r.uri === 'string' ? r.uri : null,
167
+ typeof r.mimeType === 'string' ? r.mimeType : null,
168
+ ].filter((v): v is string => v !== null);
169
+ if (meta.length > 0) return `[resource:${meta.join(' ')}]`;
170
+ }
171
+ return `[resource]`;
172
+ }
173
+
174
+ /**
175
+ * Minimal, dependency-free guard for the model's tool input against the
176
+ * server's declared JSON Schema. We deliberately do NOT pull in a full
177
+ * JSON-Schema validator (ajv) — this only enforces the two cheap invariants
178
+ * that catch the common malformed-call cases: (1) every `required` property is
179
+ * present, and (2) any declared top-level primitive `type` matches. Anything
180
+ * the server's schema doesn't constrain (nested shapes, formats, enums) is
181
+ * left to the server, so a usable schema can't reject a structurally-valid
182
+ * call. Returns a human-readable reason on the first violation, or null.
183
+ */
184
+ function validateAgainstSchema(input: unknown, schema: unknown): string | null {
185
+ if (!schema || typeof schema !== 'object') return null;
186
+ const s = schema as { type?: unknown; properties?: unknown; required?: unknown };
187
+ // Only validate object schemas — the model always emits an object here.
188
+ if (s.type !== undefined && s.type !== 'object') return null;
189
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
190
+ return 'expected an object';
191
+ }
192
+ const obj = input as Record<string, unknown>;
193
+ if (Array.isArray(s.required)) {
194
+ for (const key of s.required) {
195
+ if (typeof key === 'string' && !(key in obj)) {
196
+ return `missing required field "${key}"`;
197
+ }
198
+ }
199
+ }
200
+ if (s.properties && typeof s.properties === 'object') {
201
+ const props = s.properties as Record<string, unknown>;
202
+ for (const [key, value] of Object.entries(obj)) {
203
+ const propSchema = props[key];
204
+ if (!propSchema || typeof propSchema !== 'object') continue;
205
+ const expected = (propSchema as { type?: unknown }).type;
206
+ if (typeof expected !== 'string') continue;
207
+ if (!matchesPrimitiveType(value, expected)) {
208
+ return `field "${key}" must be of type ${expected}`;
209
+ }
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /** True when `value` satisfies a JSON-Schema primitive `type` keyword. */
216
+ function matchesPrimitiveType(value: unknown, type: string): boolean {
217
+ switch (type) {
218
+ case 'string':
219
+ return typeof value === 'string';
220
+ case 'number':
221
+ return typeof value === 'number' && Number.isFinite(value);
222
+ case 'integer':
223
+ return typeof value === 'number' && Number.isInteger(value);
224
+ case 'boolean':
225
+ return typeof value === 'boolean';
226
+ case 'object':
227
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
228
+ case 'array':
229
+ return Array.isArray(value);
230
+ case 'null':
231
+ return value === null;
232
+ default:
233
+ // Unknown/unsupported type keyword — don't reject (server decides).
234
+ return true;
235
+ }
236
+ }