@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,112 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createMutex } from '@moxxy/sdk';
3
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
4
+ import { mcpStoredConfigRootSchema, mcpStoredServerSchema } from './config-schema.js';
5
+ import type { McpStoredConfig, McpStoredServer } from './types.js';
6
+
7
+ /**
8
+ * User-level MCP server catalog persisted at ~/.moxxy/mcp.json. Mutated
9
+ * by the admin tools; read at boot by @moxxy/cli setup to spin up
10
+ * connection plugins. JSON (not yaml) for trivial parse/write — these
11
+ * entries are programmatically managed, the user doesn't normally edit
12
+ * them by hand.
13
+ */
14
+ export function mcpConfigPath(): string {
15
+ return moxxyPath('mcp.json');
16
+ }
17
+
18
+ /**
19
+ * Serializes the read-modify-write cycle of every config mutator so that
20
+ * concurrent mcp_add_server / mcp_remove_server (or enable/disable) calls
21
+ * can't interleave their read + write and clobber the file.
22
+ */
23
+ const configMutex = createMutex();
24
+
25
+ export async function readMcpConfig(): Promise<McpStoredConfig> {
26
+ try {
27
+ const raw = await fs.readFile(mcpConfigPath(), 'utf8');
28
+ const root = mcpStoredConfigRootSchema.safeParse(JSON.parse(raw));
29
+ if (!root.success) {
30
+ // The top-level shape is unusable (e.g. `servers` isn't an array) —
31
+ // treat as empty rather than crashing. The bad file is left in place
32
+ // so the user can inspect it.
33
+ return { servers: [] };
34
+ }
35
+ // Parse each entry independently and KEEP the valid ones. A single
36
+ // hand-edited bad row (e.g. a missing/empty `name`) must not strand every
37
+ // other configured server from boot/list/enable/remove — drop only the
38
+ // offending row. `.passthrough()` keeps each entry's opaque extras intact.
39
+ const servers: unknown[] = [];
40
+ for (const entry of root.data.servers) {
41
+ const parsed = mcpStoredServerSchema.safeParse(entry);
42
+ if (parsed.success) servers.push(parsed.data);
43
+ }
44
+ return { servers } as unknown as McpStoredConfig;
45
+ } catch {
46
+ // missing or malformed JSON — treat as empty
47
+ }
48
+ return { servers: [] };
49
+ }
50
+
51
+ export async function writeMcpConfig(cfg: McpStoredConfig): Promise<void> {
52
+ // Trailing newline preserved for editor-friendliness / clean diffs.
53
+ await writeFileAtomic(mcpConfigPath(), JSON.stringify(cfg, null, 2) + '\n');
54
+ }
55
+
56
+ /**
57
+ * Read-modify-write the catalog under the shared mutex. The mutator
58
+ * receives the freshly-read config and returns the next one; it is read
59
+ * and written as one critical section so concurrent admin mutators can't
60
+ * interleave and clobber each other. Returns whatever the mutator returns
61
+ * so callers can thread through derived values (e.g. the updated entry).
62
+ *
63
+ * Returning the same `cfg` object reference skips the write entirely — a
64
+ * no-op mutation (e.g. removing a name that isn't present) leaves the file
65
+ * untouched.
66
+ */
67
+ export async function mutateMcpConfig<T>(
68
+ fn: (cfg: McpStoredConfig) => { next: McpStoredConfig; result: T } | Promise<{ next: McpStoredConfig; result: T }>,
69
+ ): Promise<T> {
70
+ return configMutex.run(async () => {
71
+ const cfg = await readMcpConfig();
72
+ const { next, result } = await fn(cfg);
73
+ if (next !== cfg) await writeMcpConfig(next);
74
+ return result;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Set a server's `disabled` flag in mcp.json. Used by both `moxxy mcp
80
+ * enable/disable` (CLI) and the `/mcp` slash command (TUI) — those
81
+ * paths bypass the model and write directly. Returns the updated entry,
82
+ * or null if no server with that name exists.
83
+ *
84
+ * Runtime detach (when disabling) and lazy re-attach (when enabling)
85
+ * are NOT performed here — callers in a live session need to call into
86
+ * the admin plugin's runtime API for that.
87
+ */
88
+ export async function setServerDisabled(name: string, disabled: boolean): Promise<McpStoredServer | null> {
89
+ return mutateMcpConfig((cfg) => {
90
+ const idx = cfg.servers.findIndex((s) => s.name === name);
91
+ // Unknown name → same reference skips the write (mutateMcpConfig no-op).
92
+ if (idx < 0) return { next: cfg, result: null };
93
+ const updated: McpStoredServer = { ...cfg.servers[idx]!, disabled };
94
+ const nextServers = [...cfg.servers];
95
+ nextServers[idx] = updated;
96
+ return { next: { servers: nextServers }, result: updated };
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Drop a server from the catalog by name. Returns true if anything was
102
+ * removed. Does NOT touch a live session's tool registry.
103
+ */
104
+ export async function removeServerFromConfig(name: string): Promise<boolean> {
105
+ return mutateMcpConfig((cfg) => {
106
+ const before = cfg.servers.length;
107
+ const next = cfg.servers.filter((s) => s.name !== name);
108
+ // Nothing matched → same reference skips the write (mutateMcpConfig no-op).
109
+ if (next.length === before) return { next: cfg, result: false };
110
+ return { next: { servers: next }, result: true };
111
+ });
112
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from '@moxxy/sdk';
2
+ import type { McpStoredConfig } from './types.js';
3
+
4
+ /**
5
+ * Runtime shape guard for ~/.moxxy/mcp.json. Intentionally permissive:
6
+ * the catalog is programmatically managed and carries fields the admin
7
+ * tools care about (`name`, transport config) plus opaque extras
8
+ * (`cachedTools`, future flags) that must survive a read/write round-trip
9
+ * untouched — hence `.passthrough()` on each entry and on the root. We
10
+ * validate the structural invariant the loader relies on (a non-empty
11
+ * `name`) per entry.
12
+ */
13
+ export const mcpStoredServerSchema = z
14
+ .object({
15
+ name: z.string().min(1),
16
+ })
17
+ .passthrough();
18
+
19
+ /**
20
+ * Top-level guard only: `servers` must be an array. Each element is parsed
21
+ * INDIVIDUALLY by `readMcpConfig` against `mcpStoredServerSchema` so one bad
22
+ * row drops alone instead of stranding the whole catalog. Element type is
23
+ * `unknown` here on purpose — per-entry validation happens at read time.
24
+ */
25
+ export const mcpStoredConfigRootSchema = z
26
+ .object({
27
+ servers: z.array(z.unknown()),
28
+ })
29
+ .passthrough();
30
+
31
+ export const mcpStoredConfigSchema = z
32
+ .object({
33
+ servers: z.array(mcpStoredServerSchema),
34
+ })
35
+ .passthrough()
36
+ // The structural guard above is looser than the McpStoredConfig type
37
+ // (server transport fields are validated lazily at connect time), so
38
+ // re-assert the nominal type on the parsed output.
39
+ .transform((value) => value as unknown as McpStoredConfig);
@@ -0,0 +1,225 @@
1
+ import { definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
2
+ import { moxxyPath } from '@moxxy/sdk/server';
3
+ import type { McpSecretResolver } from './secrets.js';
4
+ import { readMcpConfig } from './config-io.js';
5
+ import { createMcpRuntime } from './runtime.js';
6
+ import { createMcpUsageSkillWriter } from './skill.js';
7
+ import { buildAddServerTool } from './tools/add.js';
8
+ import { buildListServersTool } from './tools/list.js';
9
+ import { buildRemoveServerTool } from './tools/remove.js';
10
+ import { buildTestServerTool } from './tools/test.js';
11
+ import type {
12
+ AdminSkillRegistryLike,
13
+ AdminToolRegistryLike,
14
+ BuildMcpAdminPluginOptions,
15
+ McpAdminApi,
16
+ McpStoredConfig,
17
+ McpStoredServer,
18
+ } from './types.js';
19
+
20
+ export type {
21
+ AdminSkillRegistryLike,
22
+ AdminToolRegistryLike,
23
+ BuildMcpAdminPluginOptions,
24
+ McpAdminApi,
25
+ McpRuntimeHandle,
26
+ McpServerStatus,
27
+ McpStoredConfig,
28
+ McpStoredServer,
29
+ } from './types.js';
30
+ export {
31
+ mcpConfigPath,
32
+ readMcpConfig,
33
+ removeServerFromConfig,
34
+ setServerDisabled,
35
+ writeMcpConfig,
36
+ } from './config-io.js';
37
+ export { resolveServerSecrets, type McpSecretResolver } from './secrets.js';
38
+
39
+ export function buildMcpAdminPluginWithApi(
40
+ opts: BuildMcpAdminPluginOptions = { toolRegistry: null },
41
+ ): { plugin: Plugin; api: McpAdminApi } {
42
+ return buildMcpAdminPluginInternal(opts);
43
+ }
44
+
45
+ export function buildMcpAdminPlugin(opts: BuildMcpAdminPluginOptions = { toolRegistry: null }): Plugin {
46
+ return buildMcpAdminPluginInternal(opts).plugin;
47
+ }
48
+
49
+ function lazyRegistryProxy<T extends object>(get: () => T | null, label: string): T {
50
+ return new Proxy({} as T, {
51
+ get(_target, prop) {
52
+ const resolved = get();
53
+ if (!resolved) {
54
+ throw new Error(`@moxxy/plugin-mcp: the "${label}" service is unavailable — the host must publish it`);
55
+ }
56
+ const value = (resolved as unknown as Record<string | symbol, unknown>)[prop];
57
+ return typeof value === 'function'
58
+ ? (value as (...args: unknown[]) => unknown).bind(resolved)
59
+ : value;
60
+ },
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Discovery-loadable default export. Resolves the tool + skill registries and
66
+ * the secret resolver from the inter-plugin service registry in `onInit` (the
67
+ * host publishes `'tools'`/`'skills'`; the vault plugin publishes `'resolveSecrets'`),
68
+ * then publishes its runtime control api as the `'mcpAdmin'` service — which
69
+ * core's `Session.mcpAdmin` getter exposes to the runner + desktop. Lazy
70
+ * `Proxy`s defer every registry call to the resolved instance, so the tools +
71
+ * the boot-time lazy-stub registration run unchanged once `onInit` has wired it.
72
+ */
73
+ export const mcpAdminPlugin: Plugin = (() => {
74
+ let resolvedTools: AdminToolRegistryLike | null = null;
75
+ let resolvedSkills: AdminSkillRegistryLike | null = null;
76
+ let resolvedSecrets: McpSecretResolver | null = null;
77
+
78
+ const lazyTools = lazyRegistryProxy<AdminToolRegistryLike>(() => resolvedTools, 'tools');
79
+ const lazySkills = lazyRegistryProxy<AdminSkillRegistryLike>(() => resolvedSkills, 'skills');
80
+ const secretResolver: McpSecretResolver = (value) =>
81
+ resolvedSecrets ? resolvedSecrets(value) : Promise.resolve(value);
82
+
83
+ const { plugin, api } = buildMcpAdminPluginWithApi({
84
+ toolRegistry: lazyTools,
85
+ skillRegistry: lazySkills,
86
+ secretResolver,
87
+ });
88
+ const innerOnInit = plugin.hooks?.onInit;
89
+
90
+ const hooks: LifecycleHooks = {
91
+ ...plugin.hooks,
92
+ onInit: async (ctx) => {
93
+ resolvedTools = ctx.services.get<AdminToolRegistryLike>('tools') ?? null;
94
+ resolvedSkills = ctx.services.get<AdminSkillRegistryLike>('skills') ?? null;
95
+ resolvedSecrets = ctx.services.get<McpSecretResolver>('resolveSecrets') ?? null;
96
+ ctx.services.register('mcpAdmin', api);
97
+ // Now the registry is resolved → register lazy stubs for saved servers.
98
+ await innerOnInit?.(ctx);
99
+ },
100
+ };
101
+
102
+ return definePlugin({ ...plugin, hooks });
103
+ })();
104
+
105
+ /**
106
+ * Build the MCP admin plugin: tools that let the agent register and
107
+ * manage MCP servers at runtime. When wired to a live tool registry,
108
+ * adds hot-attach so newly-registered servers are callable in the same
109
+ * session without a restart.
110
+ */
111
+ function buildMcpAdminPluginInternal(
112
+ opts: BuildMcpAdminPluginOptions,
113
+ ): { plugin: Plugin; api: McpAdminApi } {
114
+ const registry = opts.toolRegistry;
115
+ const skillRegistry = opts.skillRegistry ?? null;
116
+ const userSkillsDir = opts.userSkillsDir ?? moxxyPath('skills');
117
+ const secretResolver = opts.secretResolver ?? null;
118
+
119
+ const runtime = createMcpRuntime(registry, { secretResolver });
120
+ const writeMcpUsageSkill = createMcpUsageSkillWriter({ skillRegistry, userSkillsDir });
121
+
122
+ const api: McpAdminApi = {
123
+ enableAndAttach: async (name) => {
124
+ const cfg = await readMcpConfig();
125
+ const found = cfg.servers.find((s) => s.name === name);
126
+ if (!found) return null;
127
+ let entry: McpStoredServer = found;
128
+ if (!entry.cachedTools || entry.cachedTools.length === 0) {
129
+ entry = await runtime.refreshServerCache(entry);
130
+ }
131
+ return runtime.attachServerLazy(entry);
132
+ },
133
+ detach: runtime.detachServer,
134
+ listServers: async () => {
135
+ const cfg = await readMcpConfig();
136
+ return cfg.servers.map((s) => ({
137
+ name: s.name,
138
+ enabled: s.disabled !== true,
139
+ connected: runtime.runtimes.has(s.name),
140
+ }));
141
+ },
142
+ };
143
+
144
+ const plugin = definePlugin({
145
+ name: '@moxxy/plugin-mcp',
146
+ version: '0.0.0',
147
+ tools: [
148
+ buildListServersTool(),
149
+ buildAddServerTool({
150
+ registry,
151
+ attachServer: runtime.attachServer,
152
+ detachServer: runtime.detachServer,
153
+ writeMcpUsageSkill,
154
+ }),
155
+ buildRemoveServerTool({ detachServer: runtime.detachServer }),
156
+ buildTestServerTool({ secretResolver }),
157
+ ],
158
+ hooks: {
159
+ // On session init, register lazy stubs for every saved MCP server.
160
+ // Servers WITH a tool-descriptor cache register stubs instantly
161
+ // (no connection). Servers WITHOUT a cache (entry predates the
162
+ // cache feature, edited by hand, etc.) auto-refresh — we connect
163
+ // once, list tools, write the cache back to mcp.json, then
164
+ // register lazy stubs. The connection is closed after listing;
165
+ // subsequent tool calls reconnect via the lazy path.
166
+ onInit: async (ctx) => {
167
+ if (!registry) return;
168
+ const log = (ctx as { logger?: { warn: (msg: string, meta?: unknown) => void } }).logger;
169
+ let cfg: McpStoredConfig;
170
+ try {
171
+ cfg = await readMcpConfig();
172
+ } catch {
173
+ return;
174
+ }
175
+ // Refresh every uncached server's catalog in PARALLEL: each
176
+ // refreshServerCache now carries its own bounded connect/listTools
177
+ // timeout, so allSettled bounds boot at the slowest server instead of
178
+ // the SUM of every server's handshake latency. (Serially awaiting one
179
+ // dead endpoint after another compounded the wedge.) Cached servers
180
+ // need no connection and resolve immediately.
181
+ const active = cfg.servers.filter((s) => !s.disabled);
182
+ const resolved = await Promise.all(
183
+ active.map(async (server): Promise<McpStoredServer | null> => {
184
+ if (server.cachedTools && server.cachedTools.length > 0) return server;
185
+ try {
186
+ return await runtime.refreshServerCache(server);
187
+ } catch (err) {
188
+ log?.warn?.(`mcp: failed to refresh cache for "${server.name}"`, {
189
+ err: err instanceof Error ? err.message : String(err),
190
+ });
191
+ return null;
192
+ }
193
+ }),
194
+ );
195
+ // Attach serially in config order so tool registration order stays
196
+ // deterministic (the refresh ran concurrently, but attach is cheap and
197
+ // synchronous — no connection — so this adds no boot latency).
198
+ for (const entry of resolved) {
199
+ if (!entry) continue;
200
+ try {
201
+ runtime.attachServerLazy(entry);
202
+ } catch (err) {
203
+ log?.warn?.(`mcp: failed to attach lazy stubs for "${entry.name}"`, {
204
+ err: err instanceof Error ? err.message : String(err),
205
+ });
206
+ }
207
+ }
208
+ },
209
+ // Close every attached MCP client (lazy or eager) on session
210
+ // shutdown so stdio child processes don't get orphaned and HTTP
211
+ // sockets don't leak.
212
+ onShutdown: async () => {
213
+ for (const [, runtime_] of runtime.runtimes) {
214
+ try {
215
+ await runtime_.client.close();
216
+ } catch {
217
+ /* ignore */
218
+ }
219
+ }
220
+ runtime.runtimes.clear();
221
+ },
222
+ },
223
+ });
224
+ return { plugin, api };
225
+ }