@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,23 @@
1
+ /**
2
+ * Bounded connect/discovery timeout shared by every boot-time path that
3
+ * opens a connection and lists tools. The per-call timeout in `wrap.ts`
4
+ * (MCP_CALL_TIMEOUT_MS) only guards tool *invocations* — it does nothing
5
+ * for the connect()+listTools() handshake performed at session boot
6
+ * (refreshServerCache, eager createMcpPlugin) or on first lazy call. Without
7
+ * a cap, a single unreachable HTTP/SSE endpoint or a stdio child that spawns
8
+ * but never answers listTools blocks session boot forever (core awaits
9
+ * onInit serially), so one stale entry in mcp.json can make moxxy unstartable.
10
+ *
11
+ * 30s is generous for a real handshake (npx cold-start, slow proxy auth) but
12
+ * bounds the worst case to a skipped server rather than a wedged boot.
13
+ */
14
+ export declare const MCP_CONNECT_TIMEOUT_MS: number;
15
+ /**
16
+ * Race `work` against a hard timeout. On timeout the returned promise
17
+ * rejects; `work` itself is not cancellable here (the MCP SDK connect/list
18
+ * calls take no AbortSignal), so the caller is responsible for closing any
19
+ * handle that escapes after the reject. The timer is always cleared so it
20
+ * never keeps the event loop alive.
21
+ */
22
+ export declare function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T>;
23
+ //# sourceMappingURL=timeout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeout.d.ts","sourceRoot":"","sources":["../src/timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB,QAAY,CAAC;AAEhD;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,CAAC,EACjC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,EAChB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,CAAC,CAAC,CAUZ"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Bounded connect/discovery timeout shared by every boot-time path that
3
+ * opens a connection and lists tools. The per-call timeout in `wrap.ts`
4
+ * (MCP_CALL_TIMEOUT_MS) only guards tool *invocations* — it does nothing
5
+ * for the connect()+listTools() handshake performed at session boot
6
+ * (refreshServerCache, eager createMcpPlugin) or on first lazy call. Without
7
+ * a cap, a single unreachable HTTP/SSE endpoint or a stdio child that spawns
8
+ * but never answers listTools blocks session boot forever (core awaits
9
+ * onInit serially), so one stale entry in mcp.json can make moxxy unstartable.
10
+ *
11
+ * 30s is generous for a real handshake (npx cold-start, slow proxy auth) but
12
+ * bounds the worst case to a skipped server rather than a wedged boot.
13
+ */
14
+ export const MCP_CONNECT_TIMEOUT_MS = 30 * 1000;
15
+ /**
16
+ * Race `work` against a hard timeout. On timeout the returned promise
17
+ * rejects; `work` itself is not cancellable here (the MCP SDK connect/list
18
+ * calls take no AbortSignal), so the caller is responsible for closing any
19
+ * handle that escapes after the reject. The timer is always cleared so it
20
+ * never keeps the event loop alive.
21
+ */
22
+ export async function withTimeout(work, ms, label) {
23
+ let timer;
24
+ const timeout = new Promise((_resolve, reject) => {
25
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
26
+ });
27
+ try {
28
+ return await Promise.race([work, timeout]);
29
+ }
30
+ finally {
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+ //# sourceMappingURL=timeout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeout.js","sourceRoot":"","sources":["../src/timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,GAAG,IAAI,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAgB,EAChB,EAAU,EACV,KAAa;IAEb,IAAI,KAAgD,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE;QACtD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,oBAAoB,EAAE,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACtF,CAAC,CAAC,CAAC;IACH,IAAI,CAAC;QACH,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAC7C,CAAC;YAAS,CAAC;QACT,IAAI,KAAK;YAAE,YAAY,CAAC,KAAK,CAAC,CAAC;IACjC,CAAC;AACH,CAAC"}
@@ -0,0 +1,59 @@
1
+ export interface StdioServerConfig {
2
+ readonly kind?: 'stdio';
3
+ readonly name: string;
4
+ readonly command: string;
5
+ readonly args?: ReadonlyArray<string>;
6
+ readonly env?: Record<string, string>;
7
+ readonly cwd?: string;
8
+ }
9
+ export interface SseServerConfig {
10
+ readonly kind: 'sse';
11
+ readonly name: string;
12
+ readonly url: string;
13
+ readonly headers?: Record<string, string>;
14
+ }
15
+ export interface StreamableHttpServerConfig {
16
+ readonly kind: 'http';
17
+ readonly name: string;
18
+ readonly url: string;
19
+ readonly headers?: Record<string, string>;
20
+ }
21
+ export type McpServerConfig = StdioServerConfig | SseServerConfig | StreamableHttpServerConfig;
22
+ export interface McpPluginOptions {
23
+ readonly servers: ReadonlyArray<McpServerConfig>;
24
+ readonly clientName?: string;
25
+ readonly clientVersion?: string;
26
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
27
+ }
28
+ export interface McpClientLike {
29
+ listTools(): Promise<{
30
+ tools: ReadonlyArray<McpToolDescriptor>;
31
+ }>;
32
+ callTool(args: {
33
+ name: string;
34
+ arguments: unknown;
35
+ }): Promise<McpCallResult>;
36
+ close(): Promise<void>;
37
+ }
38
+ export interface McpToolDescriptor {
39
+ readonly name: string;
40
+ readonly description?: string;
41
+ readonly inputSchema: unknown;
42
+ }
43
+ export interface McpCallResult {
44
+ readonly content?: ReadonlyArray<McpContentBlock>;
45
+ readonly isError?: boolean;
46
+ }
47
+ export type McpContentBlock = {
48
+ readonly type: 'text';
49
+ readonly text: string;
50
+ } | {
51
+ readonly type: 'image';
52
+ readonly data: string;
53
+ readonly mimeType: string;
54
+ } | {
55
+ readonly type: 'resource';
56
+ readonly resource: unknown;
57
+ };
58
+ export declare function defaultToolNamePrefix(serverName: string, toolName: string): string;
59
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,eAAe,GAAG,iBAAiB,GAAG,eAAe,GAAG,0BAA0B,CAAC;AAE/F,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACjD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CAC5E;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAA;KAAE,CAAC,CAAC;IAClE,QAAQ,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC7E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAClD,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC5E;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC;AAE9D,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAElF"}
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ export function defaultToolNamePrefix(serverName, toolName) {
2
+ return `mcp__${serverName}__${toolName}`;
3
+ }
4
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAsDA,MAAM,UAAU,qBAAqB,CAAC,UAAkB,EAAE,QAAgB;IACxE,OAAO,QAAQ,UAAU,KAAK,QAAQ,EAAE,CAAC;AAC3C,CAAC"}
package/dist/wrap.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { type ToolDef } from '@moxxy/sdk';
2
+ import { type McpClientLike, type McpServerConfig, type McpToolDescriptor } from './types.js';
3
+ export interface WrapOptions {
4
+ readonly server: McpServerConfig;
5
+ readonly client: McpClientLike;
6
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
7
+ }
8
+ export declare function wrapMcpServerTools(opts: WrapOptions): Promise<ToolDef[]>;
9
+ /**
10
+ * Build ToolDefs from CACHED descriptors without an open client. The
11
+ * provided `getClient` factory is invoked the first time any tool runs;
12
+ * the promise is cached so subsequent calls reuse the same connection.
13
+ * Enables instant TUI boot — connections only happen when the model
14
+ * actually invokes a tool from a given MCP server.
15
+ */
16
+ export interface WrapLazyOptions {
17
+ readonly server: McpServerConfig;
18
+ readonly descriptors: ReadonlyArray<McpToolDescriptor>;
19
+ readonly getClient: () => Promise<McpClientLike>;
20
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
21
+ }
22
+ export declare function wrapMcpServerToolsLazy(opts: WrapLazyOptions): ToolDef[];
23
+ //# sourceMappingURL=wrap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wrap.d.ts","sourceRoot":"","sources":["../src/wrap.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EAEL,KAAK,aAAa,EAElB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AAgDpB,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CAC5E;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAS9E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;IACvD,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC;IACjD,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CAC5E;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,EAAE,CAKvE"}
package/dist/wrap.js ADDED
@@ -0,0 +1,193 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '@moxxy/sdk';
3
+ import { defaultToolNamePrefix, } from './types.js';
4
+ /**
5
+ * Hard cap on a single MCP tool call. The MCP SDK's `callTool` doesn't
6
+ * accept an AbortSignal, so without a timeout a hung server (crashed
7
+ * stdio child, dead websocket, blocked DB query) would hang the agent's
8
+ * tool-use loop indefinitely — leaving a permanent pending dot in the UI
9
+ * with no way to recover without killing moxxy. 5 minutes is enough room
10
+ * for slow operations (image generation, large queries) but bounded.
11
+ */
12
+ const MCP_CALL_TIMEOUT_MS = 5 * 60 * 1000;
13
+ /**
14
+ * Race the MCP call against (1) abort and (2) a hard timeout. Whichever
15
+ * settles first wins. If the underlying callTool ever does resolve after
16
+ * we've rejected, its result is silently discarded — the MCP SDK's
17
+ * cleanup is the SDK's problem.
18
+ */
19
+ async function runMcpCallWithFallback(callPromise, signal, toolName) {
20
+ return await new Promise((resolve, reject) => {
21
+ let settled = false;
22
+ const settle = (fn) => {
23
+ if (settled)
24
+ return;
25
+ settled = true;
26
+ clearTimeout(timer);
27
+ signal.removeEventListener('abort', onAbort);
28
+ fn();
29
+ };
30
+ const onAbort = () => {
31
+ settle(() => reject(new Error(`aborted MCP tool "${toolName}"`)));
32
+ };
33
+ const timer = setTimeout(() => {
34
+ settle(() => reject(new Error(`MCP tool "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS}ms`)));
35
+ }, MCP_CALL_TIMEOUT_MS);
36
+ signal.addEventListener('abort', onAbort, { once: true });
37
+ callPromise.then((v) => settle(() => resolve(v)), (err) => settle(() => reject(err instanceof Error ? err : new Error(String(err)))));
38
+ });
39
+ }
40
+ export async function wrapMcpServerTools(opts) {
41
+ const prefix = opts.toolNamePrefix ?? defaultToolNamePrefix;
42
+ const list = await opts.client.listTools();
43
+ // Eager path: the connection is already open, so the resolver is a trivial
44
+ // thunk over the live client.
45
+ const resolveClient = () => Promise.resolve(opts.client);
46
+ return list.tools.map((descriptor) => wrapOneMcpTool(descriptor, opts.server.name, resolveClient, prefix));
47
+ }
48
+ export function wrapMcpServerToolsLazy(opts) {
49
+ const prefix = opts.toolNamePrefix ?? defaultToolNamePrefix;
50
+ return opts.descriptors.map((descriptor) => wrapOneMcpTool(descriptor, opts.server.name, opts.getClient, prefix));
51
+ }
52
+ /**
53
+ * Single tool builder shared by the eager and lazy paths. They differ only in
54
+ * how the client is obtained: the eager path passes a thunk over a live client,
55
+ * the lazy path passes a `getClient` factory that connects on first call (and
56
+ * caches the connection for subsequent calls). Everything else — name, schema,
57
+ * permission, abort checks, the timeout/abort race, and result rendering — is
58
+ * identical, so it lives here once.
59
+ */
60
+ function wrapOneMcpTool(descriptor, serverName, resolveClient, prefix) {
61
+ const wrappedName = prefix(serverName, descriptor.name);
62
+ return defineTool({
63
+ name: wrappedName,
64
+ description: descriptor.description ?? `MCP tool ${descriptor.name} on server ${serverName}`,
65
+ inputSchema: z.record(z.string(), z.unknown()),
66
+ inputJsonSchema: descriptor.inputSchema ?? { type: 'object' },
67
+ permission: { action: 'prompt' },
68
+ handler: async (input, ctx) => {
69
+ if (ctx.signal.aborted)
70
+ throw new Error('aborted');
71
+ // The runtime Zod schema is a permissive record (so OpenAI accepts it),
72
+ // so whatever the model emits would otherwise reach the server verbatim.
73
+ // Fail fast with a readable message on an obviously-malformed call
74
+ // (missing required field / wrong primitive type) instead of forwarding
75
+ // garbage that may crash or hang the server (hitting the 5-min timeout).
76
+ const violation = validateAgainstSchema(input, descriptor.inputSchema);
77
+ if (violation)
78
+ return `[error] invalid arguments for ${descriptor.name}: ${violation}`;
79
+ // For the lazy path this pays the network/spawn cost only on first call
80
+ // (the factory caches its connection); for the eager path it resolves
81
+ // immediately to the already-open client.
82
+ const client = await resolveClient();
83
+ if (ctx.signal.aborted)
84
+ throw new Error('aborted');
85
+ const result = await runMcpCallWithFallback(client.callTool({ name: descriptor.name, arguments: input }), ctx.signal, wrappedName);
86
+ return renderResult(result.content, result.isError);
87
+ },
88
+ });
89
+ }
90
+ function renderResult(content, isError) {
91
+ const parts = [];
92
+ for (const block of content ?? []) {
93
+ if (block.type === 'text')
94
+ parts.push(block.text);
95
+ // Images still can't be surfaced through this string-returning handler
96
+ // (rich image passthrough would need the document/image ContentBlock
97
+ // path); keep the placeholder so the model at least knows one exists.
98
+ else if (block.type === 'image')
99
+ parts.push(`[image:${block.mimeType}]`);
100
+ else if (block.type === 'resource')
101
+ parts.push(renderResource(block.resource));
102
+ }
103
+ const text = parts.join('\n');
104
+ return isError ? `[error] ${text}` : text;
105
+ }
106
+ /**
107
+ * Surface an MCP resource block. A text resource (`resource.text`) is the
108
+ * whole point — passing it through stops the model getting a bare
109
+ * `[resource]` for content it could actually read. A binary/blob resource
110
+ * has no text representation here, so we annotate the placeholder with its
111
+ * uri/mimeType rather than swallow it silently.
112
+ */
113
+ function renderResource(resource) {
114
+ if (resource && typeof resource === 'object') {
115
+ const r = resource;
116
+ if (typeof r.text === 'string')
117
+ return r.text;
118
+ const meta = [
119
+ typeof r.uri === 'string' ? r.uri : null,
120
+ typeof r.mimeType === 'string' ? r.mimeType : null,
121
+ ].filter((v) => v !== null);
122
+ if (meta.length > 0)
123
+ return `[resource:${meta.join(' ')}]`;
124
+ }
125
+ return `[resource]`;
126
+ }
127
+ /**
128
+ * Minimal, dependency-free guard for the model's tool input against the
129
+ * server's declared JSON Schema. We deliberately do NOT pull in a full
130
+ * JSON-Schema validator (ajv) — this only enforces the two cheap invariants
131
+ * that catch the common malformed-call cases: (1) every `required` property is
132
+ * present, and (2) any declared top-level primitive `type` matches. Anything
133
+ * the server's schema doesn't constrain (nested shapes, formats, enums) is
134
+ * left to the server, so a usable schema can't reject a structurally-valid
135
+ * call. Returns a human-readable reason on the first violation, or null.
136
+ */
137
+ function validateAgainstSchema(input, schema) {
138
+ if (!schema || typeof schema !== 'object')
139
+ return null;
140
+ const s = schema;
141
+ // Only validate object schemas — the model always emits an object here.
142
+ if (s.type !== undefined && s.type !== 'object')
143
+ return null;
144
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
145
+ return 'expected an object';
146
+ }
147
+ const obj = input;
148
+ if (Array.isArray(s.required)) {
149
+ for (const key of s.required) {
150
+ if (typeof key === 'string' && !(key in obj)) {
151
+ return `missing required field "${key}"`;
152
+ }
153
+ }
154
+ }
155
+ if (s.properties && typeof s.properties === 'object') {
156
+ const props = s.properties;
157
+ for (const [key, value] of Object.entries(obj)) {
158
+ const propSchema = props[key];
159
+ if (!propSchema || typeof propSchema !== 'object')
160
+ continue;
161
+ const expected = propSchema.type;
162
+ if (typeof expected !== 'string')
163
+ continue;
164
+ if (!matchesPrimitiveType(value, expected)) {
165
+ return `field "${key}" must be of type ${expected}`;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ /** True when `value` satisfies a JSON-Schema primitive `type` keyword. */
172
+ function matchesPrimitiveType(value, type) {
173
+ switch (type) {
174
+ case 'string':
175
+ return typeof value === 'string';
176
+ case 'number':
177
+ return typeof value === 'number' && Number.isFinite(value);
178
+ case 'integer':
179
+ return typeof value === 'number' && Number.isInteger(value);
180
+ case 'boolean':
181
+ return typeof value === 'boolean';
182
+ case 'object':
183
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
184
+ case 'array':
185
+ return Array.isArray(value);
186
+ case 'null':
187
+ return value === null;
188
+ default:
189
+ // Unknown/unsupported type keyword — don't reject (server decides).
190
+ return true;
191
+ }
192
+ }
193
+ //# sourceMappingURL=wrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wrap.js","sourceRoot":"","sources":["../src/wrap.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@moxxy/plugin-mcp",
3
+ "version": "0.26.0",
4
+ "description": "MCP (Model Context Protocol) client plugin for moxxy — connect to MCP servers and surface their tools.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "mcp",
9
+ "tools",
10
+ "model-context-protocol"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-mcp"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "mcp"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.0.0",
47
+ "zod": "^3.24.0",
48
+ "@moxxy/sdk": "0.26.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.10.0",
52
+ "typescript": "^5.7.3",
53
+ "vitest": "^2.1.8",
54
+ "@moxxy/vitest-preset": "0.0.0",
55
+ "@moxxy/tsconfig": "0.0.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.json",
59
+ "typecheck": "tsc -p tsconfig.json --noEmit",
60
+ "test": "vitest run",
61
+ "clean": "rm -rf dist .turbo"
62
+ }
63
+ }
@@ -0,0 +1,188 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import {
7
+ mcpConfigPath,
8
+ mutateMcpConfig,
9
+ readMcpConfig,
10
+ removeServerFromConfig,
11
+ setServerDisabled,
12
+ writeMcpConfig,
13
+ } from './config-io.js';
14
+ import type { McpStoredConfig } from './types.js';
15
+
16
+ // config-io derives every path from moxxyPath(), which honors $MOXXY_HOME.
17
+ // Point it at a fresh tmp dir per test so we never touch the real ~/.moxxy.
18
+ describe('admin/config-io', () => {
19
+ let home: string;
20
+ const original = process.env.MOXXY_HOME;
21
+
22
+ beforeEach(async () => {
23
+ home = await mkdtemp(join(tmpdir(), 'moxxy-mcp-cfg-'));
24
+ process.env.MOXXY_HOME = home;
25
+ });
26
+
27
+ afterEach(async () => {
28
+ if (original === undefined) delete process.env.MOXXY_HOME;
29
+ else process.env.MOXXY_HOME = original;
30
+ await rm(home, { recursive: true, force: true });
31
+ });
32
+
33
+ it('writeMcpConfig -> readMcpConfig is a faithful atomic round-trip', async () => {
34
+ const cfg: McpStoredConfig = {
35
+ servers: [
36
+ { kind: 'stdio', name: 'fs', command: 'npx', args: ['-y', 'srv'] },
37
+ {
38
+ kind: 'http',
39
+ name: 'remote',
40
+ url: 'https://example.test/mcp',
41
+ // Opaque extras must survive the round-trip via .passthrough().
42
+ cachedTools: [{ name: 'ping', description: 'pong', inputSchema: { type: 'object' } }],
43
+ disabled: true,
44
+ } as never,
45
+ ],
46
+ };
47
+ await writeMcpConfig(cfg);
48
+ const back = await readMcpConfig();
49
+ expect(back).toEqual(cfg);
50
+ });
51
+
52
+ it('writes a trailing newline and leaves no temp file behind', async () => {
53
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
54
+ const raw = await fs.readFile(mcpConfigPath(), 'utf8');
55
+ expect(raw.endsWith('\n')).toBe(true);
56
+ const entries = await fs.readdir(home);
57
+ expect(entries.filter((n) => n.includes('.tmp'))).toEqual([]);
58
+ });
59
+
60
+ it('readMcpConfig returns an empty catalog when the file is absent', async () => {
61
+ expect(await readMcpConfig()).toEqual({ servers: [] });
62
+ });
63
+
64
+ it('discards malformed JSON without throwing and leaves the bad file in place', async () => {
65
+ await fs.writeFile(mcpConfigPath(), '{ this is not json', 'utf8');
66
+ expect(await readMcpConfig()).toEqual({ servers: [] });
67
+ // Bad file preserved for the user to inspect.
68
+ expect(await fs.readFile(mcpConfigPath(), 'utf8')).toBe('{ this is not json');
69
+ });
70
+
71
+ it('discards a structurally-invalid (Zod-failing) config without throwing', async () => {
72
+ // A whole row with no usable name is dropped (per-entry), and a top-level
73
+ // shape error (servers not an array) falls back to empty rather than crash.
74
+ await fs.writeFile(mcpConfigPath(), JSON.stringify({ servers: [{ name: '' }] }), 'utf8');
75
+ expect(await readMcpConfig()).toEqual({ servers: [] });
76
+
77
+ await fs.writeFile(mcpConfigPath(), JSON.stringify({ servers: 'nope' }), 'utf8');
78
+ expect(await readMcpConfig()).toEqual({ servers: [] });
79
+ });
80
+
81
+ it('keeps valid servers when one entry is malformed (u85-6: no whole-catalog wipe)', async () => {
82
+ // One good row + one nameless row. The bad row must drop ALONE — the
83
+ // valid server has to survive boot/list/enable/remove, not vanish.
84
+ await fs.writeFile(
85
+ mcpConfigPath(),
86
+ JSON.stringify({
87
+ servers: [
88
+ { kind: 'stdio', name: 'good', command: 'x' },
89
+ { kind: 'stdio', name: '', command: 'y' },
90
+ ],
91
+ }),
92
+ 'utf8',
93
+ );
94
+ const back = await readMcpConfig();
95
+ expect(back.servers.map((s) => s.name)).toEqual(['good']);
96
+ expect(back.servers[0]).toMatchObject({ kind: 'stdio', name: 'good', command: 'x' });
97
+ });
98
+
99
+ describe('setServerDisabled', () => {
100
+ it('toggles the disabled flag and returns the updated entry', async () => {
101
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
102
+ const disabled = await setServerDisabled('a', true);
103
+ expect(disabled).toMatchObject({ name: 'a', disabled: true });
104
+ expect((await readMcpConfig()).servers[0]).toMatchObject({ name: 'a', disabled: true });
105
+
106
+ const enabled = await setServerDisabled('a', false);
107
+ expect(enabled).toMatchObject({ name: 'a', disabled: false });
108
+ expect((await readMcpConfig()).servers[0]).toMatchObject({ name: 'a', disabled: false });
109
+ });
110
+
111
+ it('leaves sibling entries and per-entry fields untouched', async () => {
112
+ await writeMcpConfig({
113
+ servers: [
114
+ { kind: 'stdio', name: 'a', command: 'x', args: ['--keep'] } as never,
115
+ { kind: 'http', name: 'b', url: 'https://b.test' },
116
+ ],
117
+ });
118
+ await setServerDisabled('a', true);
119
+ const cfg = await readMcpConfig();
120
+ expect(cfg.servers[0]).toMatchObject({ name: 'a', command: 'x', args: ['--keep'], disabled: true });
121
+ expect(cfg.servers[1]).toEqual({ kind: 'http', name: 'b', url: 'https://b.test' });
122
+ });
123
+
124
+ it('returns null for an unknown server', async () => {
125
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
126
+ expect(await setServerDisabled('ghost', true)).toBeNull();
127
+ });
128
+
129
+ it('does not rewrite the file when the server is unknown (no-op skip)', async () => {
130
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
131
+ const before = await fs.stat(mcpConfigPath());
132
+ await new Promise((r) => setTimeout(r, 10));
133
+ expect(await setServerDisabled('ghost', true)).toBeNull();
134
+ const after = await fs.stat(mcpConfigPath());
135
+ expect(after.mtimeMs).toBe(before.mtimeMs);
136
+ });
137
+ });
138
+
139
+ describe('removeServerFromConfig', () => {
140
+ it('drops a server by name and reports the change', async () => {
141
+ await writeMcpConfig({
142
+ servers: [
143
+ { kind: 'stdio', name: 'a', command: 'x' },
144
+ { kind: 'stdio', name: 'b', command: 'y' },
145
+ ],
146
+ });
147
+ expect(await removeServerFromConfig('a')).toBe(true);
148
+ expect((await readMcpConfig()).servers.map((s) => s.name)).toEqual(['b']);
149
+ });
150
+
151
+ it('returns false (and changes nothing) for an unknown name', async () => {
152
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
153
+ expect(await removeServerFromConfig('ghost')).toBe(false);
154
+ expect((await readMcpConfig()).servers.map((s) => s.name)).toEqual(['a']);
155
+ });
156
+ });
157
+
158
+ describe('mutateMcpConfig', () => {
159
+ it('skips the write entirely when the mutator returns the same reference (no-op)', async () => {
160
+ await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'a', command: 'x' }] });
161
+ const before = await fs.stat(mcpConfigPath());
162
+ // Ensure mtime can differ if a write were to happen.
163
+ await new Promise((r) => setTimeout(r, 10));
164
+ const result = await mutateMcpConfig((cfg) => ({ next: cfg, result: 'noop' }));
165
+ expect(result).toBe('noop');
166
+ const after = await fs.stat(mcpConfigPath());
167
+ expect(after.mtimeMs).toBe(before.mtimeMs);
168
+ });
169
+
170
+ it('serializes concurrent mutators so neither read+write interleaves', async () => {
171
+ await writeMcpConfig({ servers: [] });
172
+ // Fire two adds concurrently. The mutex must serialize the
173
+ // read-modify-write so both land — a lost update would drop one.
174
+ await Promise.all([
175
+ mutateMcpConfig((cfg) => ({
176
+ next: { servers: [...cfg.servers, { kind: 'stdio', name: 'a', command: 'x' }] },
177
+ result: undefined,
178
+ })),
179
+ mutateMcpConfig((cfg) => ({
180
+ next: { servers: [...cfg.servers, { kind: 'stdio', name: 'b', command: 'y' }] },
181
+ result: undefined,
182
+ })),
183
+ ]);
184
+ const names = (await readMcpConfig()).servers.map((s) => s.name).sort();
185
+ expect(names).toEqual(['a', 'b']);
186
+ });
187
+ });
188
+ });