@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,244 @@
1
+ import { MoxxyError } from '@moxxy/sdk';
2
+ import type { McpClientLike, McpServerConfig, McpToolDescriptor } from '../types.js';
3
+ import { defaultClientFactory } from '../client.js';
4
+ import { MCP_CONNECT_TIMEOUT_MS, withTimeout } from '../timeout.js';
5
+ import { wrapMcpServerToolsLazy } from '../wrap.js';
6
+ import { mutateMcpConfig } from './config-io.js';
7
+ import { resolveServerSecrets, type McpSecretResolver } from './secrets.js';
8
+ import type {
9
+ AdminToolRegistryLike,
10
+ McpRuntimeHandle,
11
+ McpStoredServer,
12
+ } from './types.js';
13
+
14
+ export interface McpRuntime {
15
+ readonly runtimes: Map<string, McpRuntimeHandle>;
16
+ attachServer(server: McpServerConfig): Promise<{
17
+ toolNames: ReadonlyArray<string>;
18
+ descriptors: ReadonlyArray<McpToolDescriptor>;
19
+ }>;
20
+ attachServerLazy(server: McpStoredServer): { toolNames: ReadonlyArray<string> };
21
+ refreshServerCache(server: McpStoredServer): Promise<McpStoredServer>;
22
+ detachServer(name: string): Promise<boolean>;
23
+ }
24
+
25
+ export interface McpRuntimeOptions {
26
+ /**
27
+ * Resolves `${vault:NAME}` placeholders in env/header values at CONNECT
28
+ * time (see `secrets.ts`). The stored config — and everything persisted
29
+ * back to it — keeps the placeholder form.
30
+ */
31
+ readonly secretResolver?: McpSecretResolver | null;
32
+ }
33
+
34
+ export function createMcpRuntime(
35
+ registry: AdminToolRegistryLike | null,
36
+ options: McpRuntimeOptions = {},
37
+ ): McpRuntime {
38
+ const secretResolver = options.secretResolver ?? null;
39
+ /** Connect with secrets resolved; `server` itself stays placeholder-form. */
40
+ const connect = async (server: McpServerConfig): Promise<McpClientLike> =>
41
+ defaultClientFactory(await resolveServerSecrets(server, secretResolver));
42
+ // Track hot-attached runtimes keyed by server name. We need to know
43
+ // which tools each server contributed so `mcp_remove_server` can
44
+ // unregister them cleanly, and which client to close on shutdown.
45
+ const runtimes = new Map<string, McpRuntimeHandle>();
46
+
47
+ /**
48
+ * Eager attach used by `mcp_add_server`: connect, list tools, register
49
+ * them. Returns the discovered descriptors so the caller can cache
50
+ * them into mcp.json for lazy boots next time.
51
+ */
52
+ const attachServer: McpRuntime['attachServer'] = async (server) => {
53
+ // Bound the connect + listTools handshake. `mcp_add_server` and
54
+ // `enableAndAttach` drive this path at the model's request; a wedged
55
+ // server spawn (or a connected-but-mute endpoint that never answers
56
+ // listTools) would otherwise hang the tool call indefinitely — a
57
+ // permanent pending dot with no recovery short of killing moxxy. On
58
+ // timeout we reject AND close any client that opened, so a slow-to-spawn
59
+ // stdio child / socket can't leak after we've given up on it.
60
+ const client = await withTimeout(
61
+ connect(server),
62
+ MCP_CONNECT_TIMEOUT_MS,
63
+ `MCP connect "${server.name}"`,
64
+ );
65
+ let list: { tools: ReadonlyArray<McpToolDescriptor> };
66
+ try {
67
+ list = await withTimeout(
68
+ client.listTools(),
69
+ MCP_CONNECT_TIMEOUT_MS,
70
+ `MCP listTools "${server.name}"`,
71
+ );
72
+ } catch (err) {
73
+ await client.close().catch(() => {});
74
+ throw err;
75
+ }
76
+ const descriptors = list.tools;
77
+ // List ONCE and feed the descriptors into the lazy wrapper over the
78
+ // already-open client — wrapMcpServerTools would call listTools a second
79
+ // time, paying a redundant round-trip and risking descriptor/tool drift
80
+ // for servers whose tool list is non-deterministic between calls.
81
+ const wrapped = wrapMcpServerToolsLazy({
82
+ server,
83
+ descriptors,
84
+ getClient: () => Promise.resolve(client),
85
+ });
86
+ if (!registry) {
87
+ await client.close();
88
+ return { toolNames: wrapped.map((t) => t.name), descriptors };
89
+ }
90
+ const collisions = wrapped.filter((t) => registry.has(t.name)).map((t) => t.name);
91
+ if (collisions.length > 0) {
92
+ await client.close();
93
+ throw new MoxxyError({
94
+ code: 'CONFIG_INVALID',
95
+ message:
96
+ `mcp_add_server: tool name collision — already registered: ${collisions.join(', ')}. ` +
97
+ 'Pick a different server name (the server name becomes a prefix on each tool).',
98
+ });
99
+ }
100
+ for (const tool of wrapped) registry.register(tool);
101
+ runtimes.set(server.name, { client, toolNames: wrapped.map((t) => t.name) });
102
+ return { toolNames: wrapped.map((t) => t.name), descriptors };
103
+ };
104
+
105
+ /**
106
+ * Lazy attach used at boot: register stub tools using cached
107
+ * descriptors WITHOUT connecting. The first call to any of these
108
+ * tools triggers a single shared connection via `getOrConnect`;
109
+ * subsequent calls reuse it. Failed connections reset so the next
110
+ * call can retry.
111
+ *
112
+ * When `cachedTools` is missing (catalog entry predates the cache
113
+ * feature or was edited by hand), the caller is responsible for
114
+ * refreshing the cache first via `refreshServerCache`.
115
+ */
116
+ const attachServerLazy: McpRuntime['attachServerLazy'] = (server) => {
117
+ if (!registry) return { toolNames: [] };
118
+ if (runtimes.has(server.name)) return { toolNames: runtimes.get(server.name)!.toolNames };
119
+ const descriptors = server.cachedTools ?? [];
120
+ if (descriptors.length === 0) {
121
+ return { toolNames: [] };
122
+ }
123
+
124
+ let connectPromise: Promise<McpClientLike> | null = null;
125
+ const getOrConnect = async (): Promise<McpClientLike> => {
126
+ if (!connectPromise) {
127
+ connectPromise = (async () => {
128
+ // Bound the first-call connect so a wedged transport can't hang the
129
+ // triggering tool call forever (the per-call timeout in wrap.ts only
130
+ // covers callTool, not this connect handshake).
131
+ const client = await withTimeout(
132
+ connect(server),
133
+ MCP_CONNECT_TIMEOUT_MS,
134
+ `MCP connect "${server.name}"`,
135
+ );
136
+ // Stash the live client on the runtime entry so shutdown can
137
+ // close it. The entry was created with a sentinel; replace it.
138
+ const runtime = runtimes.get(server.name);
139
+ if (runtime) {
140
+ runtimes.set(server.name, { client, toolNames: runtime.toolNames });
141
+ return client;
142
+ }
143
+ // detachServer ran while this connect was in flight: the runtime
144
+ // entry (and its shutdown close path) is gone. Close the freshly
145
+ // opened client here so its stdio child / socket isn't orphaned,
146
+ // and fail the call rather than handing back a leaked handle.
147
+ await client.close().catch(() => {});
148
+ throw new Error(`MCP server "${server.name}" detached during connect`);
149
+ })().catch((err) => {
150
+ // Reset so a future call can retry instead of being stuck on
151
+ // a rejected promise.
152
+ connectPromise = null;
153
+ throw err;
154
+ });
155
+ }
156
+ return connectPromise;
157
+ };
158
+
159
+ const wrapped = wrapMcpServerToolsLazy({ server, descriptors, getClient: getOrConnect });
160
+ const collisions = wrapped.filter((t) => registry.has(t.name)).map((t) => t.name);
161
+ if (collisions.length > 0) {
162
+ throw new MoxxyError({
163
+ code: 'CONFIG_INVALID',
164
+ message:
165
+ `lazy attach: tool name collision for "${server.name}": ${collisions.join(', ')}. ` +
166
+ 'A different server (or a previously-attached version) already owns these names.',
167
+ });
168
+ }
169
+ for (const tool of wrapped) registry.register(tool);
170
+ // Sentinel client gets swapped for the real one inside getOrConnect.
171
+ // Until first call, close() is a no-op via the LazyClient sentinel.
172
+ const lazyClient: McpClientLike = {
173
+ listTools: async () => ({ tools: descriptors }),
174
+ callTool: async (args) => (await getOrConnect()).callTool(args),
175
+ close: async () => {
176
+ if (connectPromise) {
177
+ const client = await connectPromise.catch(() => null);
178
+ if (client) await client.close();
179
+ }
180
+ },
181
+ };
182
+ runtimes.set(server.name, { client: lazyClient, toolNames: wrapped.map((t) => t.name) });
183
+ return { toolNames: wrapped.map((t) => t.name) };
184
+ };
185
+
186
+ /**
187
+ * Connect to a server, list its tools, and write the descriptors back
188
+ * to mcp.json. Used to refresh stale or missing caches at boot. The
189
+ * connection is closed immediately — registration happens via the
190
+ * caller's subsequent `attachServerLazy` call.
191
+ */
192
+ const refreshServerCache: McpRuntime['refreshServerCache'] = async (server) => {
193
+ // Bound the connect handshake: a dead endpoint or a stdio child that
194
+ // spawns but never completes the MCP handshake would otherwise hang here,
195
+ // and at boot core awaits onInit serially — one stale entry would make
196
+ // moxxy unstartable. On timeout we reject (the onInit catch logs+skips it).
197
+ const client = await withTimeout(
198
+ connect(server),
199
+ MCP_CONNECT_TIMEOUT_MS,
200
+ `MCP connect "${server.name}"`,
201
+ );
202
+ try {
203
+ // Bound listTools too — a connected-but-unresponsive server can stall
204
+ // discovery just as badly as a dead connect.
205
+ const list = await withTimeout(
206
+ client.listTools(),
207
+ MCP_CONNECT_TIMEOUT_MS,
208
+ `MCP listTools "${server.name}"`,
209
+ );
210
+ const refreshed: McpStoredServer = { ...server, cachedTools: list.tools };
211
+ // Persist the refreshed cache so subsequent boots can lazy-attach
212
+ // without reconnecting. Read-modify-write under the shared config
213
+ // mutex so a concurrent add/remove can't clobber the file.
214
+ await mutateMcpConfig((cfg) => ({
215
+ next: { servers: cfg.servers.map((s) => (s.name === server.name ? refreshed : s)) },
216
+ result: undefined,
217
+ }));
218
+ return refreshed;
219
+ } finally {
220
+ try {
221
+ await client.close();
222
+ } catch {
223
+ /* ignore */
224
+ }
225
+ }
226
+ };
227
+
228
+ const detachServer: McpRuntime['detachServer'] = async (name) => {
229
+ const runtime = runtimes.get(name);
230
+ if (!runtime) return false;
231
+ runtimes.delete(name);
232
+ if (registry) {
233
+ for (const toolName of runtime.toolNames) registry.unregister(toolName);
234
+ }
235
+ try {
236
+ await runtime.client.close();
237
+ } catch {
238
+ // ignore — best-effort close
239
+ }
240
+ return true;
241
+ };
242
+
243
+ return { runtimes, attachServer, attachServerLazy, refreshServerCache, detachServer };
244
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+ import { addServerInput, type AddServerInput, validateAddServerInput } from './schema.js';
4
+
5
+ const input = (over: Partial<AddServerInput>): AddServerInput =>
6
+ ({ kind: 'stdio', name: 'demo', command: 'noop', autoSkill: true, ...over }) as AddServerInput;
7
+
8
+ describe('validateAddServerInput', () => {
9
+ it('builds a stdio config and strips autoSkill', () => {
10
+ const cfg = validateAddServerInput(input({ command: 'npx', args: ['x'], autoSkill: true }));
11
+ expect(cfg).toEqual({ kind: 'stdio', name: 'demo', command: 'npx', args: ['x'] });
12
+ expect('autoSkill' in cfg).toBe(false);
13
+ });
14
+
15
+ it('builds an http config and strips autoSkill', () => {
16
+ const cfg = validateAddServerInput(
17
+ input({ kind: 'http', command: undefined, url: 'https://x.test', headers: { a: 'b' } }),
18
+ );
19
+ expect(cfg).toEqual({ kind: 'http', name: 'demo', url: 'https://x.test', headers: { a: 'b' } });
20
+ expect('autoSkill' in cfg).toBe(false);
21
+ });
22
+
23
+ it('throws CONFIG_INVALID when kind="stdio" omits command', () => {
24
+ let err: unknown;
25
+ try {
26
+ validateAddServerInput(input({ kind: 'stdio', command: undefined }));
27
+ } catch (e) {
28
+ err = e;
29
+ }
30
+ expect(err).toBeInstanceOf(MoxxyError);
31
+ expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
32
+ expect((err as Error).message).toMatch(/requires a `command`/);
33
+ });
34
+
35
+ it('throws CONFIG_INVALID when kind="http" omits url', () => {
36
+ expect(() => validateAddServerInput(input({ kind: 'http', command: undefined }))).toThrow(
37
+ /requires a `url`/,
38
+ );
39
+ });
40
+
41
+ it('throws CONFIG_INVALID when kind="sse" omits url', () => {
42
+ let err: unknown;
43
+ try {
44
+ validateAddServerInput(input({ kind: 'sse', command: undefined }));
45
+ } catch (e) {
46
+ err = e;
47
+ }
48
+ expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
49
+ expect((err as Error).message).toMatch(/kind="sse"/);
50
+ });
51
+ });
52
+
53
+ describe('addServerInput url scheme allow-list (SSRF surface)', () => {
54
+ const base = { kind: 'http' as const, name: 'remote' };
55
+
56
+ it('accepts http and https urls', () => {
57
+ expect(addServerInput.safeParse({ ...base, url: 'https://mcp.example.com' }).success).toBe(true);
58
+ expect(addServerInput.safeParse({ ...base, url: 'http://localhost:3000/mcp' }).success).toBe(true);
59
+ });
60
+
61
+ it.each([
62
+ 'file:///etc/passwd',
63
+ 'gopher://internal/',
64
+ 'ws://mcp.example.com',
65
+ 'ftp://host/x',
66
+ ])('rejects non-http(s) scheme %s', (url) => {
67
+ const parsed = addServerInput.safeParse({ ...base, url });
68
+ expect(parsed.success).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,110 @@
1
+ import { MoxxyError, z } from '@moxxy/sdk';
2
+ import type { McpServerConfig } from '../types.js';
3
+
4
+ export const serverNameSchema = z
5
+ .string()
6
+ .min(1)
7
+ .max(60)
8
+ .regex(/^[a-z0-9][a-z0-9-]*$/, 'name must be slug-like (lowercase letters, digits, hyphens)');
9
+
10
+ // Flat schema (no discriminated union) so OpenAI's function-calling
11
+ // validator accepts it. OpenAI rejects top-level oneOf/anyOf with
12
+ // "object schema missing properties"; the model now sees a single
13
+ // object with `kind` + every transport-specific field optional, plus
14
+ // a runtime guard in the handler that enforces the per-kind required
15
+ // set with a readable error.
16
+ export const addServerInput = z.object({
17
+ kind: z.enum(['stdio', 'http', 'sse']).describe(
18
+ 'Transport kind. "stdio" runs a local executable; "http" and "sse" connect to a remote URL.',
19
+ ),
20
+ name: serverNameSchema,
21
+ // stdio-only fields
22
+ command: z
23
+ .string()
24
+ .min(1)
25
+ .optional()
26
+ .describe('Required when kind="stdio". Executable to spawn (e.g. "npx", "uv", "python").'),
27
+ args: z
28
+ .array(z.string())
29
+ .optional()
30
+ .describe('Optional when kind="stdio". CLI arguments for the executable.'),
31
+ env: z
32
+ .record(z.string())
33
+ .optional()
34
+ .describe(
35
+ 'Optional when kind="stdio". Environment variables for the spawned process. ' +
36
+ 'Secrets MUST be vault references ("${vault:NAME}", stored via vault_set first), ' +
37
+ 'never plaintext — placeholders are resolved at connect time only.',
38
+ ),
39
+ cwd: z
40
+ .string()
41
+ .optional()
42
+ .describe('Optional when kind="stdio". Working directory for the spawned process.'),
43
+ // http/sse-only fields
44
+ url: z
45
+ .string()
46
+ .url()
47
+ // Restrict to http(s): a bare .url() accepts file:, gopher:, ws:, etc.,
48
+ // turning a model-proposed MCP endpoint into an SSRF/file-read surface
49
+ // (e.g. file:///etc/passwd, http://169.254.169.254 metadata). The human
50
+ // permission prompt still gates the add/test, but reject obviously-wrong
51
+ // schemes before any connection is attempted.
52
+ .regex(/^https?:\/\//i, 'url must use the http:// or https:// scheme')
53
+ .optional()
54
+ .describe('Required when kind="http" or "sse". Server URL (http/https only).'),
55
+ headers: z
56
+ .record(z.string())
57
+ .optional()
58
+ .describe(
59
+ 'Optional when kind="http" or "sse". HTTP headers (auth, etc). Secrets MUST be ' +
60
+ 'vault references ("${vault:NAME}", stored via vault_set first), never plaintext — ' +
61
+ 'placeholders are resolved at connect time only.',
62
+ ),
63
+ autoSkill: z
64
+ .boolean()
65
+ .optional()
66
+ .default(true)
67
+ .describe(
68
+ 'When true (default), auto-write a deterministic usage skill ' +
69
+ '<server-name>-mcp.md into ~/.moxxy/skills/ documenting the ' +
70
+ 'tools the server exposes. Pass false if the user explicitly ' +
71
+ 'asked for no skill.',
72
+ ),
73
+ });
74
+
75
+ export type AddServerInput = z.infer<typeof addServerInput>;
76
+
77
+ export function validateAddServerInput(input: AddServerInput): McpServerConfig {
78
+ // autoSkill is consumed by the handler, not by the connection factory —
79
+ // strip it before constructing the McpServerConfig.
80
+ void input.autoSkill;
81
+ if (input.kind === 'stdio') {
82
+ if (!input.command) {
83
+ throw new MoxxyError({
84
+ code: 'CONFIG_INVALID',
85
+ message: 'mcp_add_server: kind="stdio" requires a `command` field (e.g. "npx", "uv", "python").',
86
+ });
87
+ }
88
+ const out: McpServerConfig = {
89
+ kind: 'stdio',
90
+ name: input.name,
91
+ command: input.command,
92
+ ...(input.args ? { args: input.args } : {}),
93
+ ...(input.env ? { env: input.env } : {}),
94
+ ...(input.cwd ? { cwd: input.cwd } : {}),
95
+ };
96
+ return out;
97
+ }
98
+ if (!input.url) {
99
+ throw new MoxxyError({
100
+ code: 'CONFIG_INVALID',
101
+ message: `mcp_add_server: kind="${input.kind}" requires a \`url\` field (the remote MCP endpoint).`,
102
+ });
103
+ }
104
+ return {
105
+ kind: input.kind,
106
+ name: input.name,
107
+ url: input.url,
108
+ ...(input.headers ? { headers: input.headers } : {}),
109
+ };
110
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { McpServerConfig } from '../types.js';
3
+ import { resolveServerSecrets } from './secrets.js';
4
+
5
+ const resolver = vi.fn(async (value: string) =>
6
+ value.replace(/\$\{vault:([A-Za-z0-9_.-]+)\}/g, (_m, name: string) => `sekret-${name}`),
7
+ );
8
+
9
+ describe('resolveServerSecrets', () => {
10
+ it('resolves placeholders in stdio env values', async () => {
11
+ const server: McpServerConfig = {
12
+ kind: 'stdio',
13
+ name: 'demo',
14
+ command: 'npx',
15
+ env: { API_KEY: '${vault:k}', HOME: '/home/u' },
16
+ };
17
+ const out = (await resolveServerSecrets(server, resolver)) as Extract<McpServerConfig, { command: string }>;
18
+ expect(out.env).toEqual({ API_KEY: 'sekret-k', HOME: '/home/u' });
19
+ // Original untouched — that object is what gets persisted.
20
+ expect(server.env).toEqual({ API_KEY: '${vault:k}', HOME: '/home/u' });
21
+ });
22
+
23
+ it('resolves placeholders in http/sse header values', async () => {
24
+ const server: McpServerConfig = {
25
+ kind: 'sse',
26
+ name: 'demo',
27
+ url: 'https://mcp.example.com/sse',
28
+ headers: { authorization: 'Bearer ${vault:tok}' },
29
+ };
30
+ const out = (await resolveServerSecrets(server, resolver)) as Extract<McpServerConfig, { url: string }>;
31
+ expect(out.headers).toEqual({ authorization: 'Bearer sekret-tok' });
32
+ });
33
+
34
+ it('returns the same object when nothing resolves (literal passthrough)', async () => {
35
+ const server: McpServerConfig = {
36
+ kind: 'stdio',
37
+ name: 'demo',
38
+ command: 'npx',
39
+ env: { PLAIN: 'literal-value' },
40
+ };
41
+ expect(await resolveServerSecrets(server, resolver)).toBe(server);
42
+ });
43
+
44
+ it('is the identity without a resolver (back-compat for plaintext configs)', async () => {
45
+ const server: McpServerConfig = {
46
+ kind: 'http',
47
+ name: 'demo',
48
+ url: 'https://mcp.example.com',
49
+ headers: { authorization: 'Bearer plaintext' },
50
+ };
51
+ expect(await resolveServerSecrets(server, null)).toBe(server);
52
+ expect(await resolveServerSecrets(server, undefined)).toBe(server);
53
+ });
54
+
55
+ it('treats a missing kind as stdio', async () => {
56
+ const server = { name: 'demo', command: 'npx', env: { K: '${vault:x}' } } as McpServerConfig;
57
+ const out = (await resolveServerSecrets(server, resolver)) as Extract<McpServerConfig, { command: string }>;
58
+ expect(out.env).toEqual({ K: 'sekret-x' });
59
+ });
60
+ });
@@ -0,0 +1,55 @@
1
+ import type { McpServerConfig } from '../types.js';
2
+
3
+ /**
4
+ * Connect-time secret resolution for MCP server credentials.
5
+ *
6
+ * Server entries in ~/.moxxy/mcp.json (and the `mcp_add_server` /
7
+ * `mcp_test_server` tool args) may carry `${vault:NAME}` placeholders in
8
+ * `env` values (stdio) and `headers` values (http/sse). The PLACEHOLDER is
9
+ * what gets persisted; the plaintext secret only ever materializes in the
10
+ * resolved copy handed to the transport at connect time — never in the
11
+ * model's context, never on disk.
12
+ *
13
+ * plugin-mcp deliberately has no vault dependency: setup wires a resolver
14
+ * (the CLI passes the vault's `resolveString`). A resolver MUST pass literal
15
+ * values through unchanged so pre-placeholder configs keep working.
16
+ */
17
+ export type McpSecretResolver = (value: string) => Promise<string>;
18
+
19
+ /**
20
+ * Return a copy of `server` with every `env` / `headers` value passed
21
+ * through `resolver`. With no resolver (or nothing to resolve) the original
22
+ * object is returned untouched — callers rely on that to persist the
23
+ * placeholder form, not the resolved one.
24
+ */
25
+ export async function resolveServerSecrets(
26
+ server: McpServerConfig,
27
+ resolver: McpSecretResolver | null | undefined,
28
+ ): Promise<McpServerConfig> {
29
+ if (!resolver) return server;
30
+ const kind = server.kind ?? 'stdio';
31
+ if (kind === 'http' || kind === 'sse') {
32
+ const httpish = server as Extract<McpServerConfig, { url: string }>;
33
+ if (!httpish.headers) return server;
34
+ const headers = await resolveRecord(httpish.headers, resolver);
35
+ return headers === httpish.headers ? server : { ...httpish, headers };
36
+ }
37
+ const stdio = server as Extract<McpServerConfig, { command: string }>;
38
+ if (!stdio.env) return server;
39
+ const env = await resolveRecord(stdio.env, resolver);
40
+ return env === stdio.env ? server : { ...stdio, env };
41
+ }
42
+
43
+ async function resolveRecord(
44
+ rec: Record<string, string>,
45
+ resolver: McpSecretResolver,
46
+ ): Promise<Record<string, string>> {
47
+ let changed = false;
48
+ const out: Record<string, string> = {};
49
+ for (const [key, value] of Object.entries(rec)) {
50
+ const resolved = await resolver(value);
51
+ if (resolved !== value) changed = true;
52
+ out[key] = resolved;
53
+ }
54
+ return changed ? out : rec;
55
+ }
@@ -0,0 +1,143 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { Skill } from '@moxxy/sdk';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import type { McpServerConfig, McpToolDescriptor } from '../types.js';
8
+ import { createMcpUsageSkillWriter } from './skill.js';
9
+ import type { AdminSkillRegistryLike } from './types.js';
10
+
11
+ // Minimal block-scalar / sequence frontmatter parser — independent of the
12
+ // unit under test — so "well-formed YAML" is actually parsed and checked,
13
+ // not just regexed. Mirrors @moxxy/core's hand-rolled skill frontmatter
14
+ // reader (key: scalar, plus `key:` followed by ` - item` sequences).
15
+ function parseFrontmatter(raw: string): { fm: Record<string, unknown>; body: string } {
16
+ expect(raw.startsWith('---\n')).toBe(true);
17
+ const rest = raw.slice(4);
18
+ const end = rest.indexOf('\n---\n');
19
+ expect(end).toBeGreaterThan(-1);
20
+ const fmText = rest.slice(0, end);
21
+ const body = rest.slice(end + 5).replace(/^\n/, '');
22
+ const fm: Record<string, unknown> = {};
23
+ const lines = fmText.split('\n');
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const line = lines[i]!;
26
+ if (!line.trim()) continue;
27
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
28
+ if (!m) throw new Error(`unparseable frontmatter line: ${JSON.stringify(line)}`);
29
+ const [, key, val] = m;
30
+ if (val) {
31
+ fm[key!] = val;
32
+ } else {
33
+ const items: string[] = [];
34
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1]!)) {
35
+ i++;
36
+ items.push(lines[i]!.replace(/^\s+-\s*/, '').replace(/^"(.*)"$/, '$1'));
37
+ }
38
+ fm[key!] = items;
39
+ }
40
+ }
41
+ return { fm, body };
42
+ }
43
+
44
+ const DESCRIPTORS: ReadonlyArray<McpToolDescriptor> = [
45
+ { name: 'fetch', description: 'Fetch a URL', inputSchema: { type: 'object' } },
46
+ { name: 'shell', description: undefined, inputSchema: { type: 'object' } },
47
+ ];
48
+
49
+ const server: McpServerConfig = { kind: 'stdio', name: 'acme', command: 'noop' };
50
+
51
+ const fakeRegistry = (): AdminSkillRegistryLike & { registered: Skill[] } => {
52
+ const registered: Skill[] = [];
53
+ return {
54
+ registered,
55
+ register: (s) => registered.push(s),
56
+ byName: (n) => registered.find((s) => s.frontmatter.name === n),
57
+ };
58
+ };
59
+
60
+ describe('admin/skill (createMcpUsageSkillWriter)', () => {
61
+ let dir: string;
62
+
63
+ beforeEach(async () => {
64
+ dir = await mkdtemp(join(tmpdir(), 'moxxy-mcp-skill-'));
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await rm(dir, { recursive: true, force: true });
69
+ });
70
+
71
+ it('writes a well-formed-YAML frontmatter file with the expected fields + body', async () => {
72
+ const registry = fakeRegistry();
73
+ const write = createMcpUsageSkillWriter({ skillRegistry: registry, userSkillsDir: dir });
74
+ const result = await write(server, DESCRIPTORS);
75
+ expect(result).not.toBeNull();
76
+ expect(result!.skillName).toBe('acme-mcp');
77
+ expect(result!.path).toBe(join(dir, 'acme-mcp.md'));
78
+
79
+ const raw = await readFile(result!.path, 'utf8');
80
+ const { fm, body } = parseFrontmatter(raw);
81
+ expect(fm.name).toBe('acme-mcp');
82
+ expect(fm.description).toBe('Use the acme MCP server (2 tools).');
83
+ expect(fm.triggers).toEqual(['acme', 'acme mcp', 'use acme']);
84
+ expect(fm['allowed-tools']).toEqual(['mcp__acme__fetch', 'mcp__acme__shell']);
85
+
86
+ // Body documents each tool, namespacing it, and handles the missing
87
+ // description gracefully.
88
+ expect(body).toContain('mcp__acme__fetch` — Fetch a URL');
89
+ expect(body).toContain('mcp__acme__shell` — (no description provided)');
90
+ expect(body).toContain('## Available tools');
91
+ });
92
+
93
+ it('registers a Skill object mirroring the discovered file', async () => {
94
+ const registry = fakeRegistry();
95
+ const write = createMcpUsageSkillWriter({ skillRegistry: registry, userSkillsDir: dir });
96
+ await write(server, DESCRIPTORS);
97
+ expect(registry.registered).toHaveLength(1);
98
+ const skill = registry.registered[0]!;
99
+ expect(skill.id).toBe('user/acme-mcp');
100
+ expect(skill.scope).toBe('user');
101
+ expect(skill.path).toBe(join(dir, 'acme-mcp.md'));
102
+ expect(skill.frontmatter.name).toBe('acme-mcp');
103
+ expect(skill.frontmatter['allowed-tools']).toEqual(['mcp__acme__fetch', 'mcp__acme__shell']);
104
+ expect(skill.body).toContain('## Available tools');
105
+ });
106
+
107
+ it('is a no-op (returns null, no re-register) when a skill of that name already exists', async () => {
108
+ const registry = fakeRegistry();
109
+ const write = createMcpUsageSkillWriter({ skillRegistry: registry, userSkillsDir: dir });
110
+ await write(server, DESCRIPTORS);
111
+ expect(registry.registered).toHaveLength(1);
112
+ // A second attach for the same server must not clobber user edits.
113
+ const second = await write(server, DESCRIPTORS);
114
+ expect(second).toBeNull();
115
+ expect(registry.registered).toHaveLength(1);
116
+ });
117
+
118
+ it('still writes the file (and returns a result) with no skill registry', async () => {
119
+ const write = createMcpUsageSkillWriter({ skillRegistry: null, userSkillsDir: dir });
120
+ const result = await write(server, DESCRIPTORS);
121
+ expect(result).not.toBeNull();
122
+ const raw = await readFile(result!.path, 'utf8');
123
+ expect(raw).toContain('name: acme-mcp');
124
+ });
125
+
126
+ it('reflects the tool count and lists every tool in allowed-tools + body', async () => {
127
+ const many: ReadonlyArray<McpToolDescriptor> = Array.from({ length: 5 }, (_, i) => ({
128
+ name: `t${i}`,
129
+ description: `does ${i}`,
130
+ inputSchema: { type: 'object' },
131
+ }));
132
+ const write = createMcpUsageSkillWriter({ skillRegistry: null, userSkillsDir: dir });
133
+ const result = await write(server, many);
134
+ const raw = await readFile(result!.path, 'utf8');
135
+ const { fm, body } = parseFrontmatter(raw);
136
+ expect(fm.description).toBe('Use the acme MCP server (5 tools).');
137
+ expect((fm['allowed-tools'] as string[])).toHaveLength(5);
138
+ expect((fm['allowed-tools'] as string[])[4]).toBe('mcp__acme__t4');
139
+ expect(body).toContain('mcp__acme__t4` — does 4');
140
+ // The description's defensive 240-char cap holds for normal inputs.
141
+ expect((fm.description as string).length).toBeLessThanOrEqual(240);
142
+ });
143
+ });