@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,84 @@
1
+ import { defineTool, type ToolDef } from '@moxxy/sdk';
2
+ import { defaultClientFactory } from '../../client.js';
3
+ import { MCP_CONNECT_TIMEOUT_MS, withTimeout } from '../../timeout.js';
4
+ import { wrapMcpServerTools } from '../../wrap.js';
5
+ import { addServerInput, validateAddServerInput } from '../schema.js';
6
+ import { resolveServerSecrets, type McpSecretResolver } from '../secrets.js';
7
+
8
+ export interface TestServerToolDeps {
9
+ /** Resolves `${vault:NAME}` placeholders in env/header values at connect time. */
10
+ readonly secretResolver?: McpSecretResolver | null;
11
+ }
12
+
13
+ export function buildTestServerTool(deps: TestServerToolDeps = {}): ToolDef {
14
+ return defineTool({
15
+ name: 'mcp_test_server',
16
+ description:
17
+ 'Connect to an MCP server WITHOUT saving it to config. Returns the list of tools the ' +
18
+ 'server exposes if the connection succeeds, or a connection-error message. Useful for ' +
19
+ 'sanity-checking before calling mcp_add_server. Credentials (env/header values) must be ' +
20
+ 'vault references, never plaintext: store the secret first via vault_set, then pass ' +
21
+ '"${vault:NAME}" — it is resolved at connect time.',
22
+ inputSchema: addServerInput,
23
+ // Mirrors mcp_add_server: a stdio server is an arbitrary local
24
+ // executable we spawn, so gate behind a prompt rather than running
25
+ // unknown commands silently.
26
+ permission: { action: 'prompt' },
27
+ // Honest capability surface modeled on the Bash tool: for kind="stdio"
28
+ // this spawns a child process the user named (subprocess + broad fs,
29
+ // since the command can touch anything it likes); for http/sse it
30
+ // makes an outbound connection to an arbitrary URL (net: any). Advisory
31
+ // until @moxxy/plugin-security is enabled, then enforced at call time.
32
+ isolation: {
33
+ required: 'inproc',
34
+ capabilities: {
35
+ subprocess: true,
36
+ fs: { read: ['$cwd/**', '/tmp/**'], write: ['$cwd/**', '/tmp/**'] },
37
+ net: { mode: 'any' },
38
+ env: ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM'],
39
+ timeMs: 600_000,
40
+ },
41
+ },
42
+ handler: async (input) => {
43
+ const server = validateAddServerInput(input);
44
+ let client: Awaited<ReturnType<typeof defaultClientFactory>> | null = null;
45
+ try {
46
+ // Bound the connect + listTools handshake so a wedged / unreachable
47
+ // server surfaces as a readable connection error instead of hanging
48
+ // this tool call forever (defaultClientFactory + listTools take no
49
+ // AbortSignal). On timeout the inner `connect` may still resolve a
50
+ // client after we've rejected; the finally{} below closes whatever
51
+ // `client` we captured, so a late-opening handle isn't leaked.
52
+ client = await withTimeout(
53
+ defaultClientFactory(await resolveServerSecrets(server, deps.secretResolver ?? null)),
54
+ MCP_CONNECT_TIMEOUT_MS,
55
+ `MCP connect "${server.name}"`,
56
+ );
57
+ const wrapped = await withTimeout(
58
+ wrapMcpServerTools({ server, client }),
59
+ MCP_CONNECT_TIMEOUT_MS,
60
+ `MCP listTools "${server.name}"`,
61
+ );
62
+ return {
63
+ ok: true,
64
+ name: server.name,
65
+ tools: wrapped.map((t) => ({ name: t.name, description: t.description })),
66
+ };
67
+ } catch (err) {
68
+ return {
69
+ ok: false,
70
+ name: server.name,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ };
73
+ } finally {
74
+ if (client) {
75
+ try {
76
+ await client.close();
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ }
81
+ }
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,112 @@
1
+ import type { Skill, ToolDef } from '@moxxy/sdk';
2
+ import type { McpClientLike, McpServerConfig, McpToolDescriptor } from '../types.js';
3
+ import type { McpSecretResolver } from './secrets.js';
4
+
5
+ /**
6
+ * Minimal skill-registry shape the admin plugin needs to auto-register
7
+ * a usage skill after `mcp_add_server`. Loose typing to keep this plugin
8
+ * free of an explicit @moxxy/core import.
9
+ */
10
+ export interface AdminSkillRegistryLike {
11
+ register(skill: Skill): void;
12
+ byName(name: string): Skill | undefined;
13
+ }
14
+
15
+ /**
16
+ * Live runtime: live MCP clients keyed by server name plus the set of
17
+ * tool names each one registered into the session. Lets us close +
18
+ * unregister on `mcp_remove_server` and on shutdown without
19
+ * rediscovering anything. Module-scoped so the admin plugin and the
20
+ * shutdown hook share the same state; each Session that loads the
21
+ * plugin gets its own map via the closure in `buildMcpAdminPlugin`.
22
+ */
23
+ export interface McpRuntimeHandle {
24
+ readonly client: McpClientLike;
25
+ readonly toolNames: ReadonlyArray<string>;
26
+ }
27
+
28
+ /**
29
+ * Tool-registry surface the admin plugin uses to hot-attach / detach
30
+ * MCP tools. Matches the `ToolRegistry` in @moxxy/core but typed loosely
31
+ * so we don't add an internal-dep on core from this plugin.
32
+ */
33
+ export interface AdminToolRegistryLike {
34
+ has(name: string): boolean;
35
+ register(tool: ToolDef): void;
36
+ unregister(name: string): void;
37
+ }
38
+
39
+ /**
40
+ * On-disk catalog entry: connection config PLUS a cache of the tool
41
+ * descriptors the server last advertised, plus an enable/disable flag.
42
+ *
43
+ * Defined as an intersection (not `extends`) so the McpServerConfig
44
+ * discriminated union is preserved — `extends` would collapse it.
45
+ */
46
+ export type McpStoredServer = McpServerConfig & {
47
+ readonly cachedTools?: ReadonlyArray<McpToolDescriptor>;
48
+ /** When true, the boot loader skips this entry — no lazy stubs are
49
+ * registered and tools stay invisible. Lets the user keep the
50
+ * connection config for later without paying for tool registration. */
51
+ readonly disabled?: boolean;
52
+ };
53
+
54
+ export interface McpStoredConfig {
55
+ readonly servers: ReadonlyArray<McpStoredServer>;
56
+ }
57
+
58
+ export interface BuildMcpAdminPluginOptions {
59
+ /**
60
+ * Live tool registry. When provided, `mcp_add_server` connects + wraps
61
+ * the server immediately and registers its tools into this registry —
62
+ * no restart needed. `mcp_remove_server` closes the client and
63
+ * unregisters. Pass `null` for pure-config behavior (write-only).
64
+ */
65
+ readonly toolRegistry: AdminToolRegistryLike | null;
66
+ /**
67
+ * Skill registry + skills dir. When provided, `mcp_add_server`
68
+ * auto-writes a deterministic usage skill (server-name + tool catalog)
69
+ * to disk and registers it so `/skills` and the system-prompt index
70
+ * surface the MCP server alongside hand-authored skills. The skill is
71
+ * generated from descriptors directly — no model call. Pass `null` to
72
+ * disable auto-skill creation.
73
+ */
74
+ readonly skillRegistry?: AdminSkillRegistryLike | null;
75
+ readonly userSkillsDir?: string;
76
+ /**
77
+ * Resolves `${vault:NAME}` placeholders in server env/header values at
78
+ * CONNECT time (every connect path: hot-attach, lazy attach, cache
79
+ * refresh, mcp_test_server). Wired from setup with the vault's
80
+ * `resolveString`; literal values pass through unchanged. The persisted
81
+ * catalog always keeps the placeholder form, never the plaintext.
82
+ */
83
+ readonly secretResolver?: McpSecretResolver | null;
84
+ }
85
+
86
+ /**
87
+ * Runtime control surface exposed alongside the admin Plugin. The TUI's
88
+ * /mcp slash command and the CLI's `moxxy mcp` subcommand use this to
89
+ * detach a server's live tools when disabling, or re-attach when
90
+ * enabling, without going through the model.
91
+ */
92
+ export interface McpAdminApi {
93
+ /** Refresh + lazy-attach a server (used after enabling). */
94
+ enableAndAttach(name: string): Promise<{ toolNames: ReadonlyArray<string> } | null>;
95
+ /** Detach a server's live tools and close its client. */
96
+ detach(name: string): Promise<boolean>;
97
+ /**
98
+ * One-shot snapshot of every server configured in ~/.moxxy/mcp.json,
99
+ * joined with the runtime attach state. `enabled` reflects the persisted
100
+ * config (`disabled !== true`); `connected` is true when the server's
101
+ * tools are attached to the session (whether eagerly or via a lazy stub
102
+ * — both states make tools callable from a user's perspective). Used
103
+ * by the TUI status bar to show "mcp 2/3" style summaries.
104
+ */
105
+ listServers(): Promise<ReadonlyArray<McpServerStatus>>;
106
+ }
107
+
108
+ export interface McpServerStatus {
109
+ readonly name: string;
110
+ readonly enabled: boolean;
111
+ readonly connected: boolean;
112
+ }
package/src/admin.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Backward-compatibility façade. The real implementation lives in
3
+ * `./admin/` as cohesive sub-modules; this file just re-exports the
4
+ * symbols that consumers (and the package's own `src/index.ts`) import.
5
+ */
6
+ export {
7
+ buildMcpAdminPlugin,
8
+ buildMcpAdminPluginWithApi,
9
+ mcpAdminPlugin,
10
+ mcpConfigPath,
11
+ readMcpConfig,
12
+ removeServerFromConfig,
13
+ setServerDisabled,
14
+ writeMcpConfig,
15
+ resolveServerSecrets,
16
+ type McpSecretResolver,
17
+ type AdminSkillRegistryLike,
18
+ type AdminToolRegistryLike,
19
+ type BuildMcpAdminPluginOptions,
20
+ type McpAdminApi,
21
+ type McpRuntimeHandle,
22
+ type McpServerStatus,
23
+ type McpStoredConfig,
24
+ type McpStoredServer,
25
+ } from './admin/index.js';
package/src/client.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Default MCP client factory + transport setup. Lives in its own module so
3
+ * both `index.ts` (the public surface) and `admin.ts` (the runtime
4
+ * admin/hot-attach tools) can pull `defaultClientFactory` without the two
5
+ * referencing each other — which is what would otherwise create the
6
+ * `admin.ts → index.ts → admin.ts` import cycle.
7
+ */
8
+
9
+ import type {
10
+ McpCallResult,
11
+ McpClientLike,
12
+ McpContentBlock,
13
+ McpPluginOptions,
14
+ McpServerConfig,
15
+ McpToolDescriptor,
16
+ } from './types.js';
17
+
18
+ export async function defaultClientFactory(
19
+ server: McpServerConfig,
20
+ options: McpPluginOptions = { servers: [] },
21
+ ): Promise<McpClientLike> {
22
+ const { Client } = (await import('@modelcontextprotocol/sdk/client/index.js')) as {
23
+ Client: new (info: { name: string; version: string }, capabilities: { capabilities: Record<string, unknown> }) => McpClientUntyped;
24
+ };
25
+ // Type the SDK boundary ONCE here; the rest of the factory works against the
26
+ // typed `McpClientUntyped` so a signature change in the SDK surfaces as a
27
+ // compile error rather than being papered over by per-call casts.
28
+ const client: McpClientUntyped = new Client(
29
+ { name: options.clientName ?? 'moxxy', version: options.clientVersion ?? '0.0.0' },
30
+ { capabilities: {} },
31
+ );
32
+
33
+ const transport = await createTransport(server);
34
+ await client.connect(transport);
35
+
36
+ return {
37
+ async listTools() {
38
+ const result = await client.listTools();
39
+ return { tools: (result.tools ?? []).map(toToolDescriptor) };
40
+ },
41
+ async callTool(args) {
42
+ const result = await client.callTool(args);
43
+ const out: McpCallResult = {
44
+ content: result.content?.map(toContentBlock),
45
+ isError: result.isError,
46
+ };
47
+ return out;
48
+ },
49
+ async close() {
50
+ await client.close();
51
+ },
52
+ };
53
+ }
54
+
55
+ /** Narrow one SDK tool entry to our internal descriptor at the single boundary. */
56
+ function toToolDescriptor(t: { name: string; description?: string; inputSchema: unknown }): McpToolDescriptor {
57
+ return t;
58
+ }
59
+
60
+ /** Narrow one SDK content block to our internal union at the single boundary. */
61
+ function toContentBlock(block: { type: string } & Record<string, unknown>): McpContentBlock {
62
+ return block as McpContentBlock;
63
+ }
64
+
65
+ interface McpClientUntyped {
66
+ connect(transport: unknown): Promise<void>;
67
+ listTools(): Promise<{ tools?: Array<{ name: string; description?: string; inputSchema: unknown }> }>;
68
+ callTool(args: {
69
+ name: string;
70
+ arguments: unknown;
71
+ }): Promise<{ content?: Array<{ type: string } & Record<string, unknown>>; isError?: boolean }>;
72
+ close(): Promise<void>;
73
+ }
74
+
75
+ async function createTransport(server: McpServerConfig): Promise<unknown> {
76
+ const kind: 'stdio' | 'sse' | 'http' = server.kind ?? 'stdio';
77
+ if (kind === 'stdio') {
78
+ const stdioServer = server as { command: string; args?: ReadonlyArray<string>; env?: Record<string, string>; cwd?: string };
79
+ const { StdioClientTransport } = (await import('@modelcontextprotocol/sdk/client/stdio.js')) as {
80
+ StdioClientTransport: new (config: {
81
+ command: string;
82
+ args?: string[];
83
+ env?: Record<string, string>;
84
+ cwd?: string;
85
+ stderr?: 'inherit' | 'pipe' | 'ignore' | 'overlapped' | number;
86
+ }) => unknown;
87
+ };
88
+ // Set stderr to 'ignore' so spawned subprocesses (mcp-remote, etc.)
89
+ // don't dump their boot logs into the moxxy TUI. The SDK defaults
90
+ // to 'inherit' which clobbers the chat with proxy-status lines on
91
+ // every boot. Set MOXXY_MCP_STDERR=inherit to opt back in for
92
+ // debugging.
93
+ const stderrMode: 'inherit' | 'ignore' =
94
+ process.env.MOXXY_MCP_STDERR === 'inherit' ? 'inherit' : 'ignore';
95
+ return new StdioClientTransport({
96
+ command: stdioServer.command,
97
+ args: stdioServer.args ? [...stdioServer.args] : undefined,
98
+ env: stdioServer.env,
99
+ cwd: stdioServer.cwd,
100
+ stderr: stderrMode,
101
+ });
102
+ }
103
+ const httpish = server as { url: string; headers?: Record<string, string> };
104
+ if (kind === 'sse') {
105
+ const { SSEClientTransport } = (await import('@modelcontextprotocol/sdk/client/sse.js')) as {
106
+ SSEClientTransport: new (url: URL, opts?: { requestInit?: { headers?: Record<string, string> } }) => unknown;
107
+ };
108
+ return new SSEClientTransport(new URL(httpish.url), {
109
+ requestInit: httpish.headers ? { headers: httpish.headers } : undefined,
110
+ });
111
+ }
112
+ const { StreamableHTTPClientTransport } = (await import(
113
+ '@modelcontextprotocol/sdk/client/streamableHttp.js'
114
+ )) as {
115
+ StreamableHTTPClientTransport: new (url: URL, opts?: { requestInit?: { headers?: Record<string, string> } }) => unknown;
116
+ };
117
+ return new StreamableHTTPClientTransport(new URL(httpish.url), {
118
+ requestInit: httpish.headers ? { headers: httpish.headers } : undefined,
119
+ });
120
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ServiceRegistry } from '@moxxy/sdk';
3
+ import { mcpAdminPlugin } from './index.js';
4
+
5
+ /**
6
+ * The discovery-loadable default export resolves the tools + skills registries
7
+ * and the secret resolver from the service registry in onInit, and publishes its
8
+ * runtime api as the 'mcpAdmin' service (which Session.mcpAdmin exposes), instead
9
+ * of the host `{ toolRegistry, skillRegistry, secretResolver }` closure + stash.
10
+ */
11
+ describe('mcpAdminPlugin (discovery-loadable)', () => {
12
+ it('exposes the mcp tools + an onInit hook', () => {
13
+ const names = mcpAdminPlugin.tools?.map((t) => t.name) ?? [];
14
+ expect(names).toContain('mcp_add_server');
15
+ expect(typeof mcpAdminPlugin.hooks?.onInit).toBe('function');
16
+ });
17
+
18
+ it('onInit resolves tools/skills/resolveSecrets and publishes mcpAdmin', async () => {
19
+ const reg = {
20
+ has: () => false,
21
+ register: () => {},
22
+ unregister: () => {},
23
+ byName: () => undefined,
24
+ list: () => [],
25
+ };
26
+ const get = vi.fn((name: string) =>
27
+ name === 'tools' || name === 'skills'
28
+ ? reg
29
+ : name === 'resolveSecrets'
30
+ ? async (v: string) => v
31
+ : undefined,
32
+ );
33
+ const register = vi.fn();
34
+ const services = { get, register, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
35
+
36
+ // The inner onInit reads ~/.moxxy/mcp.json (absent on CI → caught + skipped);
37
+ // we only assert the service wiring here.
38
+ await mcpAdminPlugin.hooks!.onInit!({ services } as never);
39
+
40
+ expect(get).toHaveBeenCalledWith('tools');
41
+ expect(get).toHaveBeenCalledWith('skills');
42
+ expect(get).toHaveBeenCalledWith('resolveSecrets');
43
+ expect(register).toHaveBeenCalledWith('mcpAdmin', expect.anything());
44
+ });
45
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createMcpPlugin } from './index.js';
3
+ import type { McpClientLike } from './types.js';
4
+
5
+ const fakeClient: McpClientLike = {
6
+ async listTools() {
7
+ return {
8
+ tools: [
9
+ { name: 'ping', description: 'returns pong', inputSchema: { type: 'object' } },
10
+ ],
11
+ };
12
+ },
13
+ async callTool({ name }) {
14
+ return { content: [{ type: 'text', text: `pong from ${name}` }] };
15
+ },
16
+ async close() {},
17
+ };
18
+
19
+ describe('createMcpPlugin', () => {
20
+ it('builds a Plugin whose tools wrap each MCP server', async () => {
21
+ const plugin = await createMcpPlugin({
22
+ servers: [{ name: 'demo', command: 'noop' }],
23
+ clientFactory: async () => fakeClient,
24
+ });
25
+ expect(plugin.name).toBe('@moxxy/plugin-mcp');
26
+ expect(plugin.tools).toHaveLength(1);
27
+ expect(plugin.tools![0]!.name).toBe('mcp__demo__ping');
28
+ });
29
+
30
+ it('aggregates tools across multiple servers', async () => {
31
+ const plugin = await createMcpPlugin({
32
+ servers: [
33
+ { name: 'a', command: 'noop' },
34
+ { name: 'b', command: 'noop' },
35
+ ],
36
+ clientFactory: async () => fakeClient,
37
+ });
38
+ expect(plugin.tools).toHaveLength(2);
39
+ expect(plugin.tools!.map((t) => t.name)).toEqual(['mcp__a__ping', 'mcp__b__ping']);
40
+ });
41
+
42
+ it('connects servers in parallel and bounds boot at the slowest, not the sum (u86-5)', async () => {
43
+ // Two servers whose listTools each take ~50ms. Serial boot would take
44
+ // ~100ms; parallel ~50ms. Assert well under the serial sum and that both
45
+ // tool sets land in server order.
46
+ const slowClient = (name: string): McpClientLike => ({
47
+ async listTools() {
48
+ await new Promise((r) => setTimeout(r, 50));
49
+ return { tools: [{ name: 'ping', description: 'p', inputSchema: { type: 'object' } }] };
50
+ },
51
+ async callTool() {
52
+ return { content: [{ type: 'text', text: `pong ${name}` }] };
53
+ },
54
+ async close() {},
55
+ });
56
+ const start = Date.now();
57
+ const plugin = await createMcpPlugin({
58
+ servers: [{ name: 'a', command: 'noop' }, { name: 'b', command: 'noop' }],
59
+ clientFactory: async (s) => slowClient(s.name),
60
+ });
61
+ const elapsed = Date.now() - start;
62
+ expect(plugin.tools!.map((t) => t.name)).toEqual(['mcp__a__ping', 'mcp__b__ping']);
63
+ expect(elapsed).toBeLessThan(90); // < the ~100ms serial sum
64
+ });
65
+
66
+ it('closes already-connected clients when a later server fails to connect', async () => {
67
+ const closed: string[] = [];
68
+ let n = 0;
69
+ const factory = async (server: { name: string }): Promise<McpClientLike> => {
70
+ n++;
71
+ if (n === 2) throw new Error('second server connect failed');
72
+ return { ...fakeClient, async close() { closed.push(server.name); } };
73
+ };
74
+ await expect(
75
+ createMcpPlugin({
76
+ servers: [{ name: 'a', command: 'noop' }, { name: 'b', command: 'noop' }],
77
+ clientFactory: factory,
78
+ }),
79
+ ).rejects.toThrow(/second server connect failed/);
80
+ // The first server's client must have been closed (not leaked), even though
81
+ // we never returned the plugin and so its onShutdown hook was never wired.
82
+ expect(closed).toEqual(['a']);
83
+ });
84
+
85
+ it('registers an onShutdown hook that closes all clients', async () => {
86
+ let closed = 0;
87
+ const c: McpClientLike = { ...fakeClient, async close() { closed++; } };
88
+ const plugin = await createMcpPlugin({
89
+ servers: [{ name: 'a', command: 'noop' }, { name: 'b', command: 'noop' }],
90
+ clientFactory: async () => c,
91
+ });
92
+ await plugin.hooks?.onShutdown?.({} as never);
93
+ expect(closed).toBe(2);
94
+ });
95
+ });
package/src/index.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { definePlugin, type Plugin } from '@moxxy/sdk';
2
+ import type { McpClientLike, McpPluginOptions, McpServerConfig } from './types.js';
3
+ import { defaultClientFactory } from './client.js';
4
+ import { MCP_CONNECT_TIMEOUT_MS, withTimeout } from './timeout.js';
5
+ import { wrapMcpServerTools } from './wrap.js';
6
+
7
+ export type {
8
+ McpClientLike,
9
+ McpContentBlock,
10
+ McpPluginOptions,
11
+ McpServerConfig,
12
+ McpToolDescriptor,
13
+ SseServerConfig,
14
+ StdioServerConfig,
15
+ StreamableHttpServerConfig,
16
+ } from './types.js';
17
+ export { wrapMcpServerTools } from './wrap.js';
18
+ export { defaultToolNamePrefix } from './types.js';
19
+ export { defaultClientFactory } from './client.js';
20
+ export {
21
+ buildMcpAdminPlugin,
22
+ buildMcpAdminPluginWithApi,
23
+ mcpAdminPlugin,
24
+ mcpConfigPath,
25
+ readMcpConfig,
26
+ removeServerFromConfig,
27
+ setServerDisabled,
28
+ writeMcpConfig,
29
+ resolveServerSecrets,
30
+ type McpSecretResolver,
31
+ type McpAdminApi,
32
+ type McpServerStatus,
33
+ type McpStoredConfig,
34
+ type McpStoredServer,
35
+ } from './admin.js';
36
+
37
+ export interface CreateMcpPluginOptions extends McpPluginOptions {
38
+ /**
39
+ * Inject a custom client factory. Used by tests; production code uses the
40
+ * default factory that imports `@modelcontextprotocol/sdk`.
41
+ */
42
+ readonly clientFactory?: (server: McpServerConfig, options: McpPluginOptions) => Promise<McpClientLike>;
43
+ }
44
+
45
+ export async function createMcpPlugin(opts: CreateMcpPluginOptions): Promise<Plugin> {
46
+ const factory = opts.clientFactory ?? defaultClientFactory;
47
+ // Connect + list each server in PARALLEL: serial boot paid the sum of every
48
+ // server's spawn/handshake/listTools round-trip, so N servers serialized N
49
+ // latencies. allSettled bounds boot at the slowest server, not the sum,
50
+ // while still letting us close every successfully-opened client if any leg
51
+ // fails (the onShutdown hook is only wired once we RETURN the plugin).
52
+ const opened: McpClientLike[] = [];
53
+ const results = await Promise.allSettled(
54
+ opts.servers.map(async (server) => {
55
+ // Bound the connect handshake so a single wedged server spawn surfaces
56
+ // as a rejected leg rather than stalling boot until the transport's own
57
+ // (possibly absent) timeout fires. The leg's client, if it opened before
58
+ // the timeout, is still tracked in `opened` and closed in the failure
59
+ // cleanup below.
60
+ const client = await withTimeout(
61
+ factory(server, opts),
62
+ MCP_CONNECT_TIMEOUT_MS,
63
+ `MCP connect "${server.name}"`,
64
+ );
65
+ // Record the client the instant it opens, before listTools — so even a
66
+ // listTools failure can't leak its child process / socket.
67
+ opened.push(client);
68
+ return withTimeout(
69
+ wrapMcpServerTools({ server, client, toolNamePrefix: opts.toolNamePrefix }),
70
+ MCP_CONNECT_TIMEOUT_MS,
71
+ `MCP listTools "${server.name}"`,
72
+ );
73
+ }),
74
+ );
75
+
76
+ const failure = results.find((r) => r.status === 'rejected');
77
+ if (failure) {
78
+ await Promise.allSettled(opened.map((c) => c.close()));
79
+ throw (failure as PromiseRejectedResult).reason;
80
+ }
81
+
82
+ // Flatten in server order (map preserves index → deterministic tool order).
83
+ const tools = results.flatMap((r) => (r as PromiseFulfilledResult<Awaited<ReturnType<typeof wrapMcpServerTools>>>).value);
84
+ const clients = opened;
85
+
86
+ return definePlugin({
87
+ name: '@moxxy/plugin-mcp',
88
+ version: '0.0.0',
89
+ tools,
90
+ hooks: {
91
+ onShutdown: async () => {
92
+ await Promise.allSettled(clients.map((c) => c.close()));
93
+ },
94
+ },
95
+ });
96
+ }
97
+
98
+
99
+
100
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
101
+ export { mcpAdminPlugin as default } from './admin.js';
package/src/timeout.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Bounded connect/discovery timeout shared by every boot-time path that
3
+ * opens a connection and lists tools. The per-call timeout in `wrap.ts`
4
+ * (MCP_CALL_TIMEOUT_MS) only guards tool *invocations* — it does nothing
5
+ * for the connect()+listTools() handshake performed at session boot
6
+ * (refreshServerCache, eager createMcpPlugin) or on first lazy call. Without
7
+ * a cap, a single unreachable HTTP/SSE endpoint or a stdio child that spawns
8
+ * but never answers listTools blocks session boot forever (core awaits
9
+ * onInit serially), so one stale entry in mcp.json can make moxxy unstartable.
10
+ *
11
+ * 30s is generous for a real handshake (npx cold-start, slow proxy auth) but
12
+ * bounds the worst case to a skipped server rather than a wedged boot.
13
+ */
14
+ export const MCP_CONNECT_TIMEOUT_MS = 30 * 1000;
15
+
16
+ /**
17
+ * Race `work` against a hard timeout. On timeout the returned promise
18
+ * rejects; `work` itself is not cancellable here (the MCP SDK connect/list
19
+ * calls take no AbortSignal), so the caller is responsible for closing any
20
+ * handle that escapes after the reject. The timer is always cleared so it
21
+ * never keeps the event loop alive.
22
+ */
23
+ export async function withTimeout<T>(
24
+ work: Promise<T>,
25
+ ms: number,
26
+ label: string,
27
+ ): Promise<T> {
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+ const timeout = new Promise<never>((_resolve, reject) => {
30
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
31
+ });
32
+ try {
33
+ return await Promise.race([work, timeout]);
34
+ } finally {
35
+ if (timer) clearTimeout(timer);
36
+ }
37
+ }
package/src/types.ts ADDED
@@ -0,0 +1,57 @@
1
+ export interface StdioServerConfig {
2
+ readonly kind?: 'stdio';
3
+ readonly name: string;
4
+ readonly command: string;
5
+ readonly args?: ReadonlyArray<string>;
6
+ readonly env?: Record<string, string>;
7
+ readonly cwd?: string;
8
+ }
9
+
10
+ export interface SseServerConfig {
11
+ readonly kind: 'sse';
12
+ readonly name: string;
13
+ readonly url: string;
14
+ readonly headers?: Record<string, string>;
15
+ }
16
+
17
+ export interface StreamableHttpServerConfig {
18
+ readonly kind: 'http';
19
+ readonly name: string;
20
+ readonly url: string;
21
+ readonly headers?: Record<string, string>;
22
+ }
23
+
24
+ export type McpServerConfig = StdioServerConfig | SseServerConfig | StreamableHttpServerConfig;
25
+
26
+ export interface McpPluginOptions {
27
+ readonly servers: ReadonlyArray<McpServerConfig>;
28
+ readonly clientName?: string;
29
+ readonly clientVersion?: string;
30
+ readonly toolNamePrefix?: (serverName: string, toolName: string) => string;
31
+ }
32
+
33
+ export interface McpClientLike {
34
+ listTools(): Promise<{ tools: ReadonlyArray<McpToolDescriptor> }>;
35
+ callTool(args: { name: string; arguments: unknown }): Promise<McpCallResult>;
36
+ close(): Promise<void>;
37
+ }
38
+
39
+ export interface McpToolDescriptor {
40
+ readonly name: string;
41
+ readonly description?: string;
42
+ readonly inputSchema: unknown;
43
+ }
44
+
45
+ export interface McpCallResult {
46
+ readonly content?: ReadonlyArray<McpContentBlock>;
47
+ readonly isError?: boolean;
48
+ }
49
+
50
+ export type McpContentBlock =
51
+ | { readonly type: 'text'; readonly text: string }
52
+ | { readonly type: 'image'; readonly data: string; readonly mimeType: string }
53
+ | { readonly type: 'resource'; readonly resource: unknown };
54
+
55
+ export function defaultToolNamePrefix(serverName: string, toolName: string): string {
56
+ return `mcp__${serverName}__${toolName}`;
57
+ }