@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,83 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { type Skill } from '@moxxy/sdk';
|
|
3
|
+
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
4
|
+
import type { McpServerConfig, McpToolDescriptor } from '../types.js';
|
|
5
|
+
import type { AdminSkillRegistryLike } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface WriteMcpUsageSkillOptions {
|
|
8
|
+
readonly skillRegistry: AdminSkillRegistryLike | null;
|
|
9
|
+
readonly userSkillsDir: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compose a deterministic usage skill from the server's tool list.
|
|
14
|
+
* Cheaper and more reliable than a synthesize_skill model call — the
|
|
15
|
+
* descriptors already carry name + description, so the skill body just
|
|
16
|
+
* lays them out as a bulleted checklist. The user can edit the file
|
|
17
|
+
* later if they want a richer playbook.
|
|
18
|
+
*/
|
|
19
|
+
export function createMcpUsageSkillWriter(
|
|
20
|
+
opts: WriteMcpUsageSkillOptions,
|
|
21
|
+
): (
|
|
22
|
+
server: McpServerConfig,
|
|
23
|
+
descriptors: ReadonlyArray<McpToolDescriptor>,
|
|
24
|
+
) => Promise<{ path: string; skillName: string } | null> {
|
|
25
|
+
const { skillRegistry, userSkillsDir } = opts;
|
|
26
|
+
return async (server, descriptors) => {
|
|
27
|
+
const skillName = `${server.name}-mcp`;
|
|
28
|
+
if (skillRegistry?.byName(skillName)) {
|
|
29
|
+
// Already exists (likely from a previous attach) — leave it alone
|
|
30
|
+
// so user edits aren't clobbered.
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const triggers = [server.name, `${server.name} mcp`, `use ${server.name}`];
|
|
34
|
+
const toolBullets = descriptors
|
|
35
|
+
.map((d) => {
|
|
36
|
+
const wrappedName = `mcp__${server.name}__${d.name}`;
|
|
37
|
+
return `- \`${wrappedName}\` — ${d.description ?? '(no description provided)'}`;
|
|
38
|
+
})
|
|
39
|
+
.join('\n');
|
|
40
|
+
const allowed = descriptors.map((d) => `mcp__${server.name}__${d.name}`);
|
|
41
|
+
const description = `Use the ${server.name} MCP server (${descriptors.length} tools).`.slice(0, 240);
|
|
42
|
+
const frontmatter =
|
|
43
|
+
`---\n` +
|
|
44
|
+
`name: ${skillName}\n` +
|
|
45
|
+
`description: ${description}\n` +
|
|
46
|
+
`triggers:\n${triggers.map((t) => ` - "${t}"`).join('\n')}\n` +
|
|
47
|
+
`allowed-tools:\n${allowed.map((a) => ` - ${a}`).join('\n')}\n` +
|
|
48
|
+
`---\n`;
|
|
49
|
+
const body =
|
|
50
|
+
`When the user wants to work with **${server.name}**, use the MCP tools below. Pick the tool that best matches the user's intent; chain multiple if needed.\n\n` +
|
|
51
|
+
`## Available tools\n\n${toolBullets}\n\n` +
|
|
52
|
+
`## Notes\n\n` +
|
|
53
|
+
`- Every tool above is namespaced \`mcp__${server.name}__*\`.\n` +
|
|
54
|
+
`- Auto-generated when the MCP server was registered. Edit this file by hand to refine the playbook.`;
|
|
55
|
+
const raw = `${frontmatter}\n${body}\n`;
|
|
56
|
+
const filePath = path.join(userSkillsDir, `${skillName}.md`);
|
|
57
|
+
// writeFileAtomic creates parent dirs and does a crash-safe tmp+rename.
|
|
58
|
+
await writeFileAtomic(filePath, raw);
|
|
59
|
+
if (skillRegistry) {
|
|
60
|
+
// Build a Skill object that mirrors what discoverSkills would
|
|
61
|
+
// produce so /skills, the system-prompt index, and load_skill all
|
|
62
|
+
// see it immediately.
|
|
63
|
+
const skillObject: Skill = {
|
|
64
|
+
id: `user/${skillName}` as Skill['id'],
|
|
65
|
+
path: filePath,
|
|
66
|
+
scope: 'user',
|
|
67
|
+
frontmatter: {
|
|
68
|
+
name: skillName,
|
|
69
|
+
description,
|
|
70
|
+
triggers,
|
|
71
|
+
'allowed-tools': allowed,
|
|
72
|
+
} as Skill['frontmatter'],
|
|
73
|
+
body,
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
skillRegistry.register(skillObject);
|
|
77
|
+
} catch {
|
|
78
|
+
// already registered — fine
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { path: filePath, skillName };
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { McpServerConfig, McpToolDescriptor } from '../../types.js';
|
|
6
|
+
import { readMcpConfig, writeMcpConfig } from '../config-io.js';
|
|
7
|
+
import type { AddServerInput } from '../schema.js';
|
|
8
|
+
import type { AdminToolRegistryLike } from '../types.js';
|
|
9
|
+
import { buildAddServerTool, type AddServerToolDeps } from './add.js';
|
|
10
|
+
|
|
11
|
+
const PING: McpToolDescriptor = { name: 'ping', description: 'pong', inputSchema: { type: 'object' } };
|
|
12
|
+
|
|
13
|
+
const ctx = () => ({
|
|
14
|
+
sessionId: 's' as never,
|
|
15
|
+
turnId: 't' as never,
|
|
16
|
+
callId: 'c' as never,
|
|
17
|
+
cwd: '/tmp',
|
|
18
|
+
signal: new AbortController().signal,
|
|
19
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
20
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const stdioInput = (over: Partial<AddServerInput> = {}): AddServerInput =>
|
|
24
|
+
({ kind: 'stdio', name: 'demo', command: 'noop', autoSkill: true, ...over }) as AddServerInput;
|
|
25
|
+
|
|
26
|
+
const fakeRegistry = (): AdminToolRegistryLike => ({
|
|
27
|
+
has: () => false,
|
|
28
|
+
register: () => {},
|
|
29
|
+
unregister: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('admin/tools/add (mcp_add_server)', () => {
|
|
33
|
+
let home: string;
|
|
34
|
+
const original = process.env.MOXXY_HOME;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
home = await mkdtemp(join(tmpdir(), 'moxxy-mcp-add-'));
|
|
38
|
+
process.env.MOXXY_HOME = home;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
if (original === undefined) delete process.env.MOXXY_HOME;
|
|
43
|
+
else process.env.MOXXY_HOME = original;
|
|
44
|
+
await rm(home, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const makeDeps = (over: Partial<AddServerToolDeps> = {}): {
|
|
48
|
+
deps: AddServerToolDeps;
|
|
49
|
+
attach: ReturnType<typeof vi.fn>;
|
|
50
|
+
detach: ReturnType<typeof vi.fn>;
|
|
51
|
+
writeSkill: ReturnType<typeof vi.fn>;
|
|
52
|
+
} => {
|
|
53
|
+
const attach = vi.fn(async (_server: McpServerConfig) => ({
|
|
54
|
+
toolNames: ['mcp__demo__ping'],
|
|
55
|
+
descriptors: [PING] as ReadonlyArray<McpToolDescriptor>,
|
|
56
|
+
}));
|
|
57
|
+
const detach = vi.fn(async (_name: string) => true);
|
|
58
|
+
const writeSkill = vi.fn(async (_s: McpServerConfig, _d: ReadonlyArray<McpToolDescriptor>) => ({
|
|
59
|
+
path: join(home, 'skills', 'demo-mcp.md'),
|
|
60
|
+
skillName: 'demo-mcp',
|
|
61
|
+
}));
|
|
62
|
+
const deps: AddServerToolDeps = {
|
|
63
|
+
registry: fakeRegistry(),
|
|
64
|
+
attachServer: attach as never,
|
|
65
|
+
detachServer: detach as never,
|
|
66
|
+
writeMcpUsageSkill: writeSkill as never,
|
|
67
|
+
...over,
|
|
68
|
+
};
|
|
69
|
+
return { deps, attach, detach, writeSkill };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
it('rejects a duplicate name before attaching anything', async () => {
|
|
73
|
+
await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'demo', command: 'x' }] });
|
|
74
|
+
const { deps, attach } = makeDeps();
|
|
75
|
+
const tool = buildAddServerTool(deps);
|
|
76
|
+
await expect(tool.handler(stdioInput(), ctx())).rejects.toThrow(/already exists/);
|
|
77
|
+
// Must short-circuit before paying the connection cost.
|
|
78
|
+
expect(attach).not.toHaveBeenCalled();
|
|
79
|
+
// Catalog unchanged.
|
|
80
|
+
expect((await readMcpConfig()).servers).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('attaches + persists with cached descriptors + writes the usage skill on success', async () => {
|
|
84
|
+
const { deps, attach, writeSkill } = makeDeps();
|
|
85
|
+
const tool = buildAddServerTool(deps);
|
|
86
|
+
const res = (await tool.handler(stdioInput(), ctx())) as {
|
|
87
|
+
ok: boolean;
|
|
88
|
+
attached: boolean;
|
|
89
|
+
tools: string[];
|
|
90
|
+
skill: { skillName: string } | null;
|
|
91
|
+
};
|
|
92
|
+
expect(res.ok).toBe(true);
|
|
93
|
+
expect(res.attached).toBe(true);
|
|
94
|
+
expect(res.tools).toEqual(['mcp__demo__ping']);
|
|
95
|
+
expect(res.skill?.skillName).toBe('demo-mcp');
|
|
96
|
+
expect(attach).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(writeSkill).toHaveBeenCalledTimes(1);
|
|
98
|
+
// Persisted entry caches the discovered descriptors for lazy boot.
|
|
99
|
+
const cfg = await readMcpConfig();
|
|
100
|
+
expect(cfg.servers).toHaveLength(1);
|
|
101
|
+
expect(cfg.servers[0]).toMatchObject({ name: 'demo', command: 'noop', cachedTools: [PING] });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does NOT persist or write a skill when attach fails (no broken entry on disk)', async () => {
|
|
105
|
+
const attach = vi.fn(async () => {
|
|
106
|
+
throw new Error('connect failed');
|
|
107
|
+
});
|
|
108
|
+
const { deps, writeSkill } = makeDeps({ attachServer: attach as never });
|
|
109
|
+
const tool = buildAddServerTool(deps);
|
|
110
|
+
await expect(tool.handler(stdioInput(), ctx())).rejects.toThrow(/connect failed/);
|
|
111
|
+
expect(writeSkill).not.toHaveBeenCalled();
|
|
112
|
+
expect((await readMcpConfig()).servers).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rolls back the live attach (detachServer) when the persist-time re-check loses a name race (u85-2)', async () => {
|
|
116
|
+
// attachServer succeeds (live tools registered), but a concurrent add for
|
|
117
|
+
// the same name lands between the initial read and the mutate, so the
|
|
118
|
+
// mutate's duplicate re-check throws. The registry must be rolled back.
|
|
119
|
+
const attach = vi.fn(async (_server: McpServerConfig) => {
|
|
120
|
+
// Simulate the racing add winning between read and mutate.
|
|
121
|
+
await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'demo', command: 'other' }] });
|
|
122
|
+
return {
|
|
123
|
+
toolNames: ['mcp__demo__ping'],
|
|
124
|
+
descriptors: [PING] as ReadonlyArray<McpToolDescriptor>,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const { deps, detach, writeSkill } = makeDeps({ attachServer: attach as never });
|
|
128
|
+
const tool = buildAddServerTool(deps);
|
|
129
|
+
await expect(tool.handler(stdioInput(), ctx())).rejects.toThrow(/already exists/);
|
|
130
|
+
expect(attach).toHaveBeenCalledTimes(1);
|
|
131
|
+
// The orphaned registration + open client must be torn down.
|
|
132
|
+
expect(detach).toHaveBeenCalledWith('demo');
|
|
133
|
+
// No usage skill written, and the racing winner's entry is left intact
|
|
134
|
+
// (exactly one entry, command from the winner — not ours).
|
|
135
|
+
expect(writeSkill).not.toHaveBeenCalled();
|
|
136
|
+
const cfg = await readMcpConfig();
|
|
137
|
+
expect(cfg.servers).toHaveLength(1);
|
|
138
|
+
expect(cfg.servers[0]).toMatchObject({ name: 'demo', command: 'other' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('skips the usage skill when autoSkill is false', async () => {
|
|
142
|
+
const { deps, writeSkill } = makeDeps();
|
|
143
|
+
const tool = buildAddServerTool(deps);
|
|
144
|
+
const res = (await tool.handler(stdioInput({ autoSkill: false }), ctx())) as { skill: unknown };
|
|
145
|
+
expect(writeSkill).not.toHaveBeenCalled();
|
|
146
|
+
expect(res.skill).toBeNull();
|
|
147
|
+
// Server still attached + persisted.
|
|
148
|
+
expect((await readMcpConfig()).servers).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports skillError but still succeeds when skill writing throws', async () => {
|
|
152
|
+
const writeSkill = vi.fn(async () => {
|
|
153
|
+
throw new Error('disk full');
|
|
154
|
+
});
|
|
155
|
+
const { deps } = makeDeps({ writeMcpUsageSkill: writeSkill as never });
|
|
156
|
+
const tool = buildAddServerTool(deps);
|
|
157
|
+
const res = (await tool.handler(stdioInput(), ctx())) as {
|
|
158
|
+
ok: boolean;
|
|
159
|
+
skill: unknown;
|
|
160
|
+
skillError?: string;
|
|
161
|
+
};
|
|
162
|
+
expect(res.ok).toBe(true);
|
|
163
|
+
expect(res.skill).toBeNull();
|
|
164
|
+
expect(res.skillError).toMatch(/disk full/);
|
|
165
|
+
// Attach + persist still succeeded despite the skill failure.
|
|
166
|
+
expect((await readMcpConfig()).servers).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('reports attached:false (config-only) when no live registry is wired', async () => {
|
|
170
|
+
const { deps } = makeDeps({ registry: null });
|
|
171
|
+
const tool = buildAddServerTool(deps);
|
|
172
|
+
const res = (await tool.handler(stdioInput(), ctx())) as { attached: boolean; note: string };
|
|
173
|
+
expect(res.attached).toBe(false);
|
|
174
|
+
expect(res.note).toMatch(/Restart moxxy/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('persists an http server with its url + headers', async () => {
|
|
178
|
+
const { deps } = makeDeps();
|
|
179
|
+
const tool = buildAddServerTool(deps);
|
|
180
|
+
await tool.handler(
|
|
181
|
+
stdioInput({ kind: 'http', name: 'remote', command: undefined, url: 'https://r.test', headers: { a: 'b' } }),
|
|
182
|
+
ctx(),
|
|
183
|
+
);
|
|
184
|
+
const cfg = await readMcpConfig();
|
|
185
|
+
expect(cfg.servers[0]).toMatchObject({
|
|
186
|
+
kind: 'http',
|
|
187
|
+
name: 'remote',
|
|
188
|
+
url: 'https://r.test',
|
|
189
|
+
headers: { a: 'b' },
|
|
190
|
+
cachedTools: [PING],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { defineTool, MoxxyError, type ToolDef } from '@moxxy/sdk';
|
|
2
|
+
import type { McpServerConfig, McpToolDescriptor } from '../../types.js';
|
|
3
|
+
import { mcpConfigPath, mutateMcpConfig, readMcpConfig } from '../config-io.js';
|
|
4
|
+
import { addServerInput, validateAddServerInput } from '../schema.js';
|
|
5
|
+
import type {
|
|
6
|
+
AdminToolRegistryLike,
|
|
7
|
+
McpStoredServer,
|
|
8
|
+
} from '../types.js';
|
|
9
|
+
|
|
10
|
+
export interface AddServerToolDeps {
|
|
11
|
+
readonly registry: AdminToolRegistryLike | null;
|
|
12
|
+
attachServer(server: McpServerConfig): Promise<{
|
|
13
|
+
toolNames: ReadonlyArray<string>;
|
|
14
|
+
descriptors: ReadonlyArray<McpToolDescriptor>;
|
|
15
|
+
}>;
|
|
16
|
+
/** Roll back a hot-attach (unregister its tools + close the client) when
|
|
17
|
+
* persisting the new entry fails after `attachServer` already succeeded. */
|
|
18
|
+
detachServer(name: string): Promise<boolean>;
|
|
19
|
+
writeMcpUsageSkill(
|
|
20
|
+
server: McpServerConfig,
|
|
21
|
+
descriptors: ReadonlyArray<McpToolDescriptor>,
|
|
22
|
+
): Promise<{ path: string; skillName: string } | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildAddServerTool(deps: AddServerToolDeps): ToolDef {
|
|
26
|
+
const { registry, attachServer, detachServer, writeMcpUsageSkill } = deps;
|
|
27
|
+
return defineTool({
|
|
28
|
+
name: 'mcp_add_server',
|
|
29
|
+
description:
|
|
30
|
+
'Register a new MCP server in ~/.moxxy/mcp.json. Pick "stdio" for local commands ' +
|
|
31
|
+
'(npm/uv packages, scripts); pick "http" or "sse" for remote HTTP servers. The new ' +
|
|
32
|
+
'server\'s tools become available after the next moxxy restart. Call mcp_test_server ' +
|
|
33
|
+
'first if you want to verify connectivity before persisting. NEVER pass API keys or ' +
|
|
34
|
+
'tokens in plaintext: store the secret in the vault first (vault_set), then reference ' +
|
|
35
|
+
'it as "${vault:NAME}" in the env/header value — the placeholder is what gets ' +
|
|
36
|
+
'persisted, and it is resolved only at connect time.',
|
|
37
|
+
inputSchema: addServerInput,
|
|
38
|
+
permission: { action: 'prompt' },
|
|
39
|
+
handler: async (input) => {
|
|
40
|
+
const server = validateAddServerInput(input);
|
|
41
|
+
const cfg = await readMcpConfig();
|
|
42
|
+
if (cfg.servers.some((s) => s.name === server.name)) {
|
|
43
|
+
throw new MoxxyError({
|
|
44
|
+
code: 'CONFIG_INVALID',
|
|
45
|
+
message:
|
|
46
|
+
`mcp_add_server: an MCP server named "${server.name}" already exists. ` +
|
|
47
|
+
`Use mcp_remove_server first, or pick a different name.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Hot-attach: connect + register tools BEFORE persisting. If
|
|
51
|
+
// attach fails (bad URL, missing command, schema mismatch),
|
|
52
|
+
// we never write a broken entry to disk.
|
|
53
|
+
const { toolNames, descriptors } = await attachServer(server);
|
|
54
|
+
// Cache descriptors so next boot can register lazy stubs
|
|
55
|
+
// without paying the connection cost up-front.
|
|
56
|
+
const stored: McpStoredServer = { ...server, cachedTools: descriptors };
|
|
57
|
+
// Persist under the shared config mutex, re-reading the latest
|
|
58
|
+
// catalog so a concurrent add/remove can't clobber the file. The
|
|
59
|
+
// duplicate check is repeated against the fresh read to catch a race
|
|
60
|
+
// where two adds for the same name both passed the initial check.
|
|
61
|
+
try {
|
|
62
|
+
await mutateMcpConfig((current) => {
|
|
63
|
+
if (current.servers.some((s) => s.name === server.name)) {
|
|
64
|
+
throw new MoxxyError({
|
|
65
|
+
code: 'CONFIG_INVALID',
|
|
66
|
+
message:
|
|
67
|
+
`mcp_add_server: an MCP server named "${server.name}" already exists. ` +
|
|
68
|
+
`Use mcp_remove_server first, or pick a different name.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return { next: { servers: [...current.servers, stored] }, result: undefined };
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// attachServer already registered the live tools + opened a client, but
|
|
75
|
+
// persistence failed (e.g. a concurrent add for the same name won the
|
|
76
|
+
// race, or the file write threw). Roll the live registry back so we
|
|
77
|
+
// don't strand callable tools with no config entry + a leaked client.
|
|
78
|
+
await detachServer(server.name).catch(() => undefined);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
// Auto-create the usage skill so /skills surfaces the new
|
|
82
|
+
// server alongside hand-authored skills. Best-effort — if
|
|
83
|
+
// skill writing fails, the MCP attach still succeeded.
|
|
84
|
+
let skillResult: { path: string; skillName: string } | null = null;
|
|
85
|
+
if (input.autoSkill !== false) {
|
|
86
|
+
try {
|
|
87
|
+
skillResult = await writeMcpUsageSkill(server, descriptors);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
skillResult = null;
|
|
90
|
+
// surface but don't fail the whole tool call
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
name: server.name,
|
|
94
|
+
path: mcpConfigPath(),
|
|
95
|
+
attached: registry !== null,
|
|
96
|
+
tools: toolNames,
|
|
97
|
+
skill: null,
|
|
98
|
+
skillError: err instanceof Error ? err.message : String(err),
|
|
99
|
+
note: 'Server attached + persisted; skill creation failed (see skillError).',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
name: server.name,
|
|
106
|
+
path: mcpConfigPath(),
|
|
107
|
+
attached: registry !== null,
|
|
108
|
+
tools: toolNames,
|
|
109
|
+
skill: skillResult,
|
|
110
|
+
note: registry
|
|
111
|
+
? `Live in this session — ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} now callable.` +
|
|
112
|
+
(skillResult ? ` Usage skill written to ${skillResult.path}.` : '') +
|
|
113
|
+
' Persisted; survives restart.'
|
|
114
|
+
: 'Saved to config. Restart moxxy to load the tools (no live registry was wired into the admin plugin).',
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineTool, z, type ToolDef } from '@moxxy/sdk';
|
|
2
|
+
import { readMcpConfig } from '../config-io.js';
|
|
3
|
+
|
|
4
|
+
export function buildListServersTool(): ToolDef {
|
|
5
|
+
return defineTool({
|
|
6
|
+
name: 'mcp_list_servers',
|
|
7
|
+
description:
|
|
8
|
+
'List every MCP server currently registered in ~/.moxxy/mcp.json. Returns name + transport kind + connection details (command/url) for each.',
|
|
9
|
+
inputSchema: z.object({}),
|
|
10
|
+
handler: async () => {
|
|
11
|
+
const cfg = await readMcpConfig();
|
|
12
|
+
return cfg.servers.map((s) =>
|
|
13
|
+
s.kind === undefined || s.kind === 'stdio'
|
|
14
|
+
? { name: s.name, kind: 'stdio' as const, command: (s as { command: string }).command }
|
|
15
|
+
: { name: s.name, kind: s.kind, url: (s as { url: string }).url },
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { readMcpConfig, writeMcpConfig } from '../config-io.js';
|
|
6
|
+
import { buildRemoveServerTool } from './remove.js';
|
|
7
|
+
|
|
8
|
+
const ctx = () => ({
|
|
9
|
+
sessionId: 's' as never,
|
|
10
|
+
turnId: 't' as never,
|
|
11
|
+
callId: 'c' as never,
|
|
12
|
+
cwd: '/tmp',
|
|
13
|
+
signal: new AbortController().signal,
|
|
14
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
15
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('admin/tools/remove (mcp_remove_server)', () => {
|
|
19
|
+
let home: string;
|
|
20
|
+
const original = process.env.MOXXY_HOME;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
home = await mkdtemp(join(tmpdir(), 'moxxy-mcp-rm-'));
|
|
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('removes from config AND detaches from the live session', async () => {
|
|
34
|
+
await writeMcpConfig({
|
|
35
|
+
servers: [
|
|
36
|
+
{ kind: 'stdio', name: 'demo', command: 'x' },
|
|
37
|
+
{ kind: 'stdio', name: 'keep', command: 'y' },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
const detach = vi.fn(async () => true);
|
|
41
|
+
const tool = buildRemoveServerTool({ detachServer: detach });
|
|
42
|
+
const res = (await tool.handler({ name: 'demo' }, ctx())) as {
|
|
43
|
+
removed: boolean;
|
|
44
|
+
persistedChange: boolean;
|
|
45
|
+
detachedFromSession: boolean;
|
|
46
|
+
};
|
|
47
|
+
expect(res).toMatchObject({ removed: true, persistedChange: true, detachedFromSession: true });
|
|
48
|
+
expect(detach).toHaveBeenCalledWith('demo');
|
|
49
|
+
expect((await readMcpConfig()).servers.map((s) => s.name)).toEqual(['keep']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reports removed:true when only the live session had it (config no-op)', async () => {
|
|
53
|
+
await writeMcpConfig({ servers: [] });
|
|
54
|
+
const detach = vi.fn(async () => true);
|
|
55
|
+
const tool = buildRemoveServerTool({ detachServer: detach });
|
|
56
|
+
const res = (await tool.handler({ name: 'ephemeral' }, ctx())) as {
|
|
57
|
+
removed: boolean;
|
|
58
|
+
persistedChange: boolean;
|
|
59
|
+
detachedFromSession: boolean;
|
|
60
|
+
};
|
|
61
|
+
expect(res).toMatchObject({ removed: true, persistedChange: false, detachedFromSession: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports removed:false when nothing matched in config or session', async () => {
|
|
65
|
+
await writeMcpConfig({ servers: [{ kind: 'stdio', name: 'demo', command: 'x' }] });
|
|
66
|
+
const detach = vi.fn(async () => false);
|
|
67
|
+
const tool = buildRemoveServerTool({ detachServer: detach });
|
|
68
|
+
const res = (await tool.handler({ name: 'ghost' }, ctx())) as { removed: boolean; note: string };
|
|
69
|
+
expect(res.removed).toBe(false);
|
|
70
|
+
expect(res.note).toMatch(/No MCP server named "ghost"/);
|
|
71
|
+
// Existing entry untouched.
|
|
72
|
+
expect((await readMcpConfig()).servers.map((s) => s.name)).toEqual(['demo']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineTool, z, type ToolDef } from '@moxxy/sdk';
|
|
2
|
+
import { mutateMcpConfig } from '../config-io.js';
|
|
3
|
+
import { serverNameSchema } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
export interface RemoveServerToolDeps {
|
|
6
|
+
detachServer(name: string): Promise<boolean>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildRemoveServerTool(deps: RemoveServerToolDeps): ToolDef {
|
|
10
|
+
const { detachServer } = deps;
|
|
11
|
+
return defineTool({
|
|
12
|
+
name: 'mcp_remove_server',
|
|
13
|
+
description:
|
|
14
|
+
'Remove an MCP server from ~/.moxxy/mcp.json and detach its tools from the live session. ' +
|
|
15
|
+
'The tools become uncallable immediately and the entry is gone on next restart.',
|
|
16
|
+
inputSchema: z.object({ name: serverNameSchema }),
|
|
17
|
+
permission: { action: 'prompt' },
|
|
18
|
+
handler: async ({ name }) => {
|
|
19
|
+
// Read-modify-write under the shared config mutex so a concurrent
|
|
20
|
+
// add/remove can't clobber the file.
|
|
21
|
+
const persisted = await mutateMcpConfig((cfg) => {
|
|
22
|
+
const filtered = cfg.servers.filter((s) => s.name !== name);
|
|
23
|
+
if (filtered.length === cfg.servers.length) {
|
|
24
|
+
// Nothing matched — return the same reference to skip the write.
|
|
25
|
+
return { next: cfg, result: false };
|
|
26
|
+
}
|
|
27
|
+
return { next: { servers: filtered }, result: true };
|
|
28
|
+
});
|
|
29
|
+
// Runtime detach runs OUTSIDE the config mutex (the live tool registry
|
|
30
|
+
// isn't guarded by it), so a concurrent same-name add could interleave
|
|
31
|
+
// between this persist and detach. Detach is best-effort and loosely
|
|
32
|
+
// coupled to the config — the entry is authoritative, the runtime map
|
|
33
|
+
// self-heals on the next attach/restart.
|
|
34
|
+
const detached = await detachServer(name);
|
|
35
|
+
if (!persisted && !detached) {
|
|
36
|
+
return { removed: false, name, note: `No MCP server named "${name}" was registered.` };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
removed: true,
|
|
40
|
+
name,
|
|
41
|
+
persistedChange: persisted,
|
|
42
|
+
detachedFromSession: detached,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { McpClientLike, McpServerConfig } from '../../types.js';
|
|
3
|
+
import type { AddServerInput } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
// mcp_test_server connects through `defaultClientFactory` (../../client.js).
|
|
6
|
+
// Inject a fake so the test never spawns a real subprocess / opens a socket.
|
|
7
|
+
const hoisted = vi.hoisted(() => ({
|
|
8
|
+
impl: null as ((server: McpServerConfig) => Promise<McpClientLike>) | null,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../../client.js', () => ({
|
|
12
|
+
defaultClientFactory: async (server: McpServerConfig): Promise<McpClientLike> => {
|
|
13
|
+
if (!hoisted.impl) throw new Error('impl not set');
|
|
14
|
+
return hoisted.impl(server);
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { buildTestServerTool } = await import('./test.js');
|
|
19
|
+
|
|
20
|
+
const ctx = () => ({
|
|
21
|
+
sessionId: 's' as never,
|
|
22
|
+
turnId: 't' as never,
|
|
23
|
+
callId: 'c' as never,
|
|
24
|
+
cwd: '/tmp',
|
|
25
|
+
signal: new AbortController().signal,
|
|
26
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
27
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const input = (over: Partial<AddServerInput> = {}): AddServerInput =>
|
|
31
|
+
({ kind: 'stdio', name: 'demo', command: 'noop', autoSkill: true, ...over }) as AddServerInput;
|
|
32
|
+
|
|
33
|
+
describe('admin/tools/test (mcp_test_server)', () => {
|
|
34
|
+
it('returns ok + the tool list when the connection succeeds, and closes the client', async () => {
|
|
35
|
+
let closed = 0;
|
|
36
|
+
hoisted.impl = async () => ({
|
|
37
|
+
listTools: async () => ({
|
|
38
|
+
tools: [{ name: 'ping', description: 'pong', inputSchema: { type: 'object' } }],
|
|
39
|
+
}),
|
|
40
|
+
callTool: async () => ({ content: [] }),
|
|
41
|
+
close: async () => {
|
|
42
|
+
closed++;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const tool = buildTestServerTool();
|
|
46
|
+
const res = (await tool.handler(input(), ctx())) as {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
name: string;
|
|
49
|
+
tools: Array<{ name: string; description?: string }>;
|
|
50
|
+
};
|
|
51
|
+
expect(res.ok).toBe(true);
|
|
52
|
+
expect(res.name).toBe('demo');
|
|
53
|
+
expect(res.tools).toEqual([{ name: 'mcp__demo__ping', description: 'pong' }]);
|
|
54
|
+
expect(closed).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns ok:false with the error message when the connection fails (no throw)', async () => {
|
|
58
|
+
hoisted.impl = async () => {
|
|
59
|
+
throw new Error('connect refused');
|
|
60
|
+
};
|
|
61
|
+
const tool = buildTestServerTool();
|
|
62
|
+
const res = (await tool.handler(input(), ctx())) as { ok: boolean; error: string };
|
|
63
|
+
expect(res.ok).toBe(false);
|
|
64
|
+
expect(res.error).toMatch(/connect refused/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('still closes the client when listTools throws after connecting', async () => {
|
|
68
|
+
let closed = 0;
|
|
69
|
+
hoisted.impl = async () => ({
|
|
70
|
+
listTools: async () => {
|
|
71
|
+
throw new Error('list boom');
|
|
72
|
+
},
|
|
73
|
+
callTool: async () => ({ content: [] }),
|
|
74
|
+
close: async () => {
|
|
75
|
+
closed++;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const tool = buildTestServerTool();
|
|
79
|
+
const res = (await tool.handler(input(), ctx())) as { ok: boolean; error: string };
|
|
80
|
+
expect(res.ok).toBe(false);
|
|
81
|
+
expect(res.error).toMatch(/list boom/);
|
|
82
|
+
expect(closed).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('bounded connect/discovery (no infinite hang)', () => {
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns ok:false with a timeout error when connect never resolves', async () => {
|
|
91
|
+
vi.useFakeTimers();
|
|
92
|
+
// A wedged / unreachable server must NOT hang the tool call forever —
|
|
93
|
+
// the bounded connect surfaces a readable timeout as a normal failure.
|
|
94
|
+
hoisted.impl = () => new Promise<McpClientLike>(() => {});
|
|
95
|
+
const tool = buildTestServerTool();
|
|
96
|
+
const promise = tool.handler(input(), ctx()) as Promise<{ ok: boolean; error: string }>;
|
|
97
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
98
|
+
const res = await promise;
|
|
99
|
+
expect(res.ok).toBe(false);
|
|
100
|
+
expect(res.error).toMatch(/timed out/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns ok:false with a timeout error when listTools never resolves, and closes the client', async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
let closed = 0;
|
|
106
|
+
hoisted.impl = async () => ({
|
|
107
|
+
listTools: () => new Promise<{ tools: never[] }>(() => {}),
|
|
108
|
+
callTool: async () => ({ content: [] }),
|
|
109
|
+
close: async () => {
|
|
110
|
+
closed++;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const tool = buildTestServerTool();
|
|
114
|
+
const promise = tool.handler(input(), ctx()) as Promise<{ ok: boolean; error: string }>;
|
|
115
|
+
await vi.advanceTimersByTimeAsync(30 * 1000);
|
|
116
|
+
const res = await promise;
|
|
117
|
+
expect(res.ok).toBe(false);
|
|
118
|
+
expect(res.error).toMatch(/timed out/);
|
|
119
|
+
// The finally{} must still close the connected-but-mute client.
|
|
120
|
+
expect(closed).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|