@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,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
|
+
}
|