@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.
- package/LICENSE +21 -0
- package/dist/admin/config-io.d.ts +46 -0
- package/dist/admin/config-io.d.ts.map +1 -0
- package/dist/admin/config-io.js +108 -0
- package/dist/admin/config-io.js.map +1 -0
- package/dist/admin/config-schema.d.ts +65 -0
- package/dist/admin/config-schema.d.ts.map +1 -0
- package/dist/admin/config-schema.js +36 -0
- package/dist/admin/config-schema.js.map +1 -0
- package/dist/admin/index.d.ts +21 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +189 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/runtime.d.ts +25 -0
- package/dist/admin/runtime.d.ts.map +1 -0
- package/dist/admin/runtime.js +196 -0
- package/dist/admin/runtime.js.map +1 -0
- package/dist/admin/schema.d.ts +37 -0
- package/dist/admin/schema.d.ts.map +1 -0
- package/dist/admin/schema.js +97 -0
- package/dist/admin/schema.js.map +1 -0
- package/dist/admin/secrets.d.ts +24 -0
- package/dist/admin/secrets.d.ts.map +1 -0
- package/dist/admin/secrets.js +35 -0
- package/dist/admin/secrets.js.map +1 -0
- package/dist/admin/skill.d.ts +18 -0
- package/dist/admin/skill.d.ts.map +1 -0
- package/dist/admin/skill.js +70 -0
- package/dist/admin/skill.js.map +1 -0
- package/dist/admin/tools/add.d.ts +19 -0
- package/dist/admin/tools/add.d.ts.map +1 -0
- package/dist/admin/tools/add.js +97 -0
- package/dist/admin/tools/add.js.map +1 -0
- package/dist/admin/tools/list.d.ts +3 -0
- package/dist/admin/tools/list.d.ts.map +1 -0
- package/dist/admin/tools/list.js +16 -0
- package/dist/admin/tools/list.js.map +1 -0
- package/dist/admin/tools/remove.d.ts +6 -0
- package/dist/admin/tools/remove.d.ts.map +1 -0
- package/dist/admin/tools/remove.js +41 -0
- package/dist/admin/tools/remove.js.map +1 -0
- package/dist/admin/tools/test.d.ts +8 -0
- package/dist/admin/tools/test.d.ts.map +1 -0
- package/dist/admin/tools/test.js +73 -0
- package/dist/admin/tools/test.js.map +1 -0
- package/dist/admin/types.d.ts +107 -0
- package/dist/admin/types.d.ts.map +1 -0
- package/dist/admin/types.js +2 -0
- package/dist/admin/types.js.map +1 -0
- package/dist/admin.d.ts +7 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +7 -0
- package/dist/admin.js.map +1 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +73 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/timeout.d.ts +23 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +35 -0
- package/dist/timeout.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/wrap.d.ts +23 -0
- package/dist/wrap.d.ts.map +1 -0
- package/dist/wrap.js +193 -0
- package/dist/wrap.js.map +1 -0
- package/package.json +63 -0
- package/src/admin/config-io.test.ts +188 -0
- package/src/admin/config-io.ts +112 -0
- package/src/admin/config-schema.ts +39 -0
- package/src/admin/index.ts +225 -0
- package/src/admin/runtime.test.ts +470 -0
- package/src/admin/runtime.ts +244 -0
- package/src/admin/schema.test.ts +70 -0
- package/src/admin/schema.ts +110 -0
- package/src/admin/secrets.test.ts +60 -0
- package/src/admin/secrets.ts +55 -0
- package/src/admin/skill.test.ts +143 -0
- package/src/admin/skill.ts +83 -0
- package/src/admin/tools/add.test.ts +193 -0
- package/src/admin/tools/add.ts +118 -0
- package/src/admin/tools/list.ts +19 -0
- package/src/admin/tools/remove.test.ts +74 -0
- package/src/admin/tools/remove.ts +46 -0
- package/src/admin/tools/test.test.ts +123 -0
- package/src/admin/tools/test.ts +84 -0
- package/src/admin/types.ts +112 -0
- package/src/admin.ts +25 -0
- package/src/client.ts +120 -0
- package/src/discovery.test.ts +45 -0
- package/src/index.test.ts +95 -0
- package/src/index.ts +101 -0
- package/src/timeout.ts +37 -0
- package/src/types.ts +57 -0
- package/src/wrap.test.ts +261 -0
- 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
|
+
});
|