@electric-ax/agents-mcp 0.2.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/dist/index.js ADDED
@@ -0,0 +1,1131 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import fs from "node:fs/promises";
5
+ import fs$1 from "node:fs";
6
+ import { spawn } from "node:child_process";
7
+ import path from "node:path";
8
+
9
+ //#region src/tools.ts
10
+ const MCP_TOOLS_SENTINEL = Symbol.for(`@electric-ax/agents-mcp/tools-sentinel`);
11
+ function isMcpToolsSentinel(x) {
12
+ return !!x && typeof x === `object` && x[MCP_TOOLS_SENTINEL] === true;
13
+ }
14
+ const mcp = { tools(allowlist) {
15
+ return [{
16
+ [MCP_TOOLS_SENTINEL]: true,
17
+ allowlist
18
+ }];
19
+ } };
20
+ function filterByAllowlist(serverNames, allowlist) {
21
+ if (allowlist === void 0) return [...serverNames];
22
+ const set = new Set(allowlist);
23
+ return serverNames.filter((n) => set.has(n));
24
+ }
25
+
26
+ //#endregion
27
+ //#region src/credentials/auth-store.ts
28
+ function createAuthStore() {
29
+ const tokens = new Map();
30
+ const clients = new Map();
31
+ const hooks = new Map();
32
+ return {
33
+ seedTokens(server, t) {
34
+ tokens.set(server, t);
35
+ },
36
+ seedClient(server, c) {
37
+ clients.set(server, c);
38
+ },
39
+ registerHooks(server, h) {
40
+ hooks.set(server, h);
41
+ },
42
+ clearCredentials(server) {
43
+ tokens.delete(server);
44
+ clients.delete(server);
45
+ },
46
+ forget(server) {
47
+ tokens.delete(server);
48
+ clients.delete(server);
49
+ hooks.delete(server);
50
+ },
51
+ getOAuthTokens(server) {
52
+ return tokens.get(server);
53
+ },
54
+ async saveOAuthTokens(server, t) {
55
+ tokens.set(server, t);
56
+ const h = hooks.get(server);
57
+ if (h?.onTokensChanged) await h.onTokensChanged(t);
58
+ },
59
+ getOAuthClientInfo(server) {
60
+ return clients.get(server);
61
+ },
62
+ async saveOAuthClientInfo(server, c) {
63
+ clients.set(server, c);
64
+ const h = hooks.get(server);
65
+ if (h?.onClientRegistered) await h.onClientRegistered(c);
66
+ }
67
+ };
68
+ }
69
+
70
+ //#endregion
71
+ //#region src/transports/http.ts
72
+ function createHttpTransport(opts) {
73
+ const fetchImpl = opts.fetchImpl ?? fetch;
74
+ const transport = new StreamableHTTPClientTransport(new URL(opts.url), {
75
+ authProvider: opts.authProvider,
76
+ fetch: opts.headerProvider ? async (url, init) => {
77
+ const headers = new Headers(init?.headers);
78
+ const h = await opts.headerProvider();
79
+ if (h) headers.set(h.name, h.value);
80
+ return fetchImpl(url, {
81
+ ...init,
82
+ headers
83
+ });
84
+ } : fetchImpl
85
+ });
86
+ const client = new Client({
87
+ name: `@electric-ax/agents-mcp`,
88
+ version: `0.1.0`
89
+ }, { capabilities: {} });
90
+ return {
91
+ client,
92
+ async connect() {
93
+ await client.connect(transport);
94
+ },
95
+ async close() {
96
+ await client.close();
97
+ }
98
+ };
99
+ }
100
+
101
+ //#endregion
102
+ //#region src/transports/stdio.ts
103
+ function createStdioTransport(opts) {
104
+ const transport = new StdioClientTransport({
105
+ command: opts.command,
106
+ args: opts.args ?? [],
107
+ env: opts.env
108
+ });
109
+ const client = new Client({
110
+ name: `@electric-ax/agents-mcp`,
111
+ version: `0.1.0`
112
+ }, { capabilities: {} });
113
+ return {
114
+ client,
115
+ async connect() {
116
+ await client.connect(transport);
117
+ },
118
+ async close() {
119
+ await client.close();
120
+ }
121
+ };
122
+ }
123
+
124
+ //#endregion
125
+ //#region src/auth/api-key.ts
126
+ function buildApiKeyHeader(apiKey, opts = {}) {
127
+ return {
128
+ name: opts.headerName ?? `Authorization`,
129
+ value: (opts.valuePrefix ?? ``) + apiKey
130
+ };
131
+ }
132
+
133
+ //#endregion
134
+ //#region src/auth/sdk-provider.ts
135
+ function createSdkOAuthProvider(opts) {
136
+ const redirect = opts.redirectUri ?? `${opts.publicUrl.replace(/\/$/, ``)}/oauth/callback/${opts.server}`;
137
+ let codeVerifier;
138
+ let lastAuthUrl;
139
+ const toSdkTokens = (t) => ({
140
+ access_token: t.accessToken,
141
+ refresh_token: t.refreshToken,
142
+ expires_in: t.expiresAt ? Math.max(0, t.expiresAt - Math.floor(Date.now() / 1e3)) : void 0,
143
+ token_type: t.tokenType ?? `Bearer`,
144
+ scope: t.scope
145
+ });
146
+ const fromSdkTokens = (t) => ({
147
+ accessToken: t.access_token,
148
+ refreshToken: t.refresh_token,
149
+ expiresAt: t.expires_in ? Math.floor(Date.now() / 1e3) + t.expires_in : void 0,
150
+ tokenType: t.token_type,
151
+ scope: t.scope
152
+ });
153
+ const toSdkClientInfo = (c) => ({
154
+ client_id: c.clientId,
155
+ client_secret: c.clientSecret,
156
+ redirect_uris: c.redirectUris ?? [redirect]
157
+ });
158
+ const fromSdkClientInfo = (c) => ({
159
+ clientId: c.client_id,
160
+ clientSecret: c.client_secret,
161
+ redirectUris: c.redirect_uris?.map(String),
162
+ registeredAt: Math.floor(Date.now() / 1e3)
163
+ });
164
+ return {
165
+ get redirectUrl() {
166
+ return redirect;
167
+ },
168
+ get clientMetadata() {
169
+ return {
170
+ client_name: `@electric-ax/agents-mcp`,
171
+ redirect_uris: [redirect],
172
+ grant_types: [`authorization_code`, `refresh_token`],
173
+ response_types: [`code`],
174
+ token_endpoint_auth_method: `client_secret_post`,
175
+ scope: opts.scopes?.join(` `)
176
+ };
177
+ },
178
+ async clientInformation() {
179
+ const saved = opts.authStore.getOAuthClientInfo(opts.server);
180
+ return saved ? toSdkClientInfo(saved) : void 0;
181
+ },
182
+ async saveClientInformation(info) {
183
+ await opts.authStore.saveOAuthClientInfo(opts.server, fromSdkClientInfo(info));
184
+ },
185
+ async tokens() {
186
+ const saved = opts.authStore.getOAuthTokens(opts.server);
187
+ return saved ? toSdkTokens(saved) : void 0;
188
+ },
189
+ async saveTokens(tokens) {
190
+ await opts.authStore.saveOAuthTokens(opts.server, fromSdkTokens(tokens));
191
+ },
192
+ redirectToAuthorization(url) {
193
+ lastAuthUrl = url.toString();
194
+ },
195
+ saveCodeVerifier(v) {
196
+ codeVerifier = v;
197
+ },
198
+ async codeVerifier() {
199
+ if (!codeVerifier) throw new Error(`No PKCE codeVerifier set for "${opts.server}"`);
200
+ return codeVerifier;
201
+ },
202
+ peekAuthUrl() {
203
+ return lastAuthUrl;
204
+ },
205
+ clearAuthUrl() {
206
+ lastAuthUrl = void 0;
207
+ }
208
+ };
209
+ }
210
+
211
+ //#endregion
212
+ //#region src/auth/client-credentials.ts
213
+ /**
214
+ * Minimal OAuthClientProvider implementing only what's needed for clientCredentials:
215
+ * lazy fetches a token on `tokens()`. The SDK's transport uses `tokens()` to attach
216
+ * Authorization headers and re-calls on 401.
217
+ */
218
+ function createClientCredentialsProvider(opts) {
219
+ let cached;
220
+ return {
221
+ get redirectUrl() {
222
+ return ``;
223
+ },
224
+ get clientMetadata() {
225
+ return {};
226
+ },
227
+ async clientInformation() {
228
+ return {
229
+ client_id: opts.clientId,
230
+ client_secret: opts.clientSecret
231
+ };
232
+ },
233
+ async saveClientInformation() {},
234
+ async tokens() {
235
+ const now = Math.floor(Date.now() / 1e3);
236
+ if (cached && cached.expiresAt - 30 > now) return {
237
+ access_token: cached.access_token,
238
+ token_type: `Bearer`
239
+ };
240
+ const body = new URLSearchParams({
241
+ grant_type: `client_credentials`,
242
+ client_id: opts.clientId,
243
+ client_secret: opts.clientSecret
244
+ });
245
+ if (opts.scopes?.length) body.set(`scope`, opts.scopes.join(` `));
246
+ if (opts.audience) body.set(`audience`, opts.audience);
247
+ if (opts.resource) body.set(`resource`, opts.resource);
248
+ const res = await fetch(opts.tokenUrl, {
249
+ method: `POST`,
250
+ headers: { "Content-Type": `application/x-www-form-urlencoded` },
251
+ body
252
+ });
253
+ if (!res.ok) throw new Error(`clientCredentials token endpoint ${res.status}`);
254
+ const json = await res.json();
255
+ cached = {
256
+ access_token: json.access_token,
257
+ expiresAt: now + (json.expires_in ?? 300)
258
+ };
259
+ return {
260
+ access_token: json.access_token,
261
+ token_type: `Bearer`
262
+ };
263
+ },
264
+ async saveTokens() {},
265
+ redirectToAuthorization() {},
266
+ saveCodeVerifier() {},
267
+ async codeVerifier() {
268
+ return ``;
269
+ }
270
+ };
271
+ }
272
+
273
+ //#endregion
274
+ //#region src/registry.ts
275
+ function hashConfig(c) {
276
+ const parts = [
277
+ c.name,
278
+ c.transport,
279
+ c.url ?? ``,
280
+ c.auth?.mode ?? `none`,
281
+ String(c.timeoutMs ?? ``)
282
+ ];
283
+ if (c.auth && (c.auth.mode === `authorizationCode` || c.auth.mode === `clientCredentials`)) parts.push((c.auth.scopes ?? []).slice().sort().join(`,`));
284
+ if (c.transport === `stdio`) parts.push(c.command, (c.args ?? []).join(` `));
285
+ return parts.join(`|`);
286
+ }
287
+ function makeError(kind, message) {
288
+ return {
289
+ kind,
290
+ message
291
+ };
292
+ }
293
+ function createRegistry(opts) {
294
+ const entries = new Map();
295
+ const authStore = opts.authStore ?? createAuthStore();
296
+ const buildTransport = async (cfg) => {
297
+ if (cfg.transport === `stdio`) {
298
+ if (opts.transportFactoryOverride) return { transport: opts.transportFactoryOverride(cfg) };
299
+ return { transport: createStdioTransport({
300
+ name: cfg.name,
301
+ command: cfg.command,
302
+ args: cfg.args,
303
+ env: cfg.env
304
+ }) };
305
+ }
306
+ if (cfg.auth.mode === `none`) {
307
+ if (opts.transportFactoryOverride) return { transport: opts.transportFactoryOverride(cfg) };
308
+ return { transport: createHttpTransport({
309
+ name: cfg.name,
310
+ url: cfg.url
311
+ }) };
312
+ }
313
+ if (cfg.auth.mode === `apiKey`) {
314
+ if (opts.transportFactoryOverride) return { transport: opts.transportFactoryOverride(cfg) };
315
+ if (!cfg.auth.key) return { error: makeError(`auth_unavailable`, `no auth.key for ${cfg.name} (apiKey mode requires the secret inline)`) };
316
+ const header = buildApiKeyHeader(cfg.auth.key, {
317
+ headerName: cfg.auth.headerName,
318
+ valuePrefix: cfg.auth.valuePrefix
319
+ });
320
+ const headerProvider = async () => header;
321
+ return { transport: createHttpTransport({
322
+ name: cfg.name,
323
+ url: cfg.url,
324
+ headerProvider
325
+ }) };
326
+ }
327
+ if (cfg.auth.mode === `authorizationCode`) {
328
+ const publicUrl = opts.publicUrl ?? `http://localhost`;
329
+ const provider = createSdkOAuthProvider({
330
+ server: cfg.name,
331
+ publicUrl,
332
+ authStore,
333
+ scopes: cfg.auth.scopes,
334
+ redirectUri: cfg.auth.redirectUri,
335
+ resource: cfg.auth.resource
336
+ });
337
+ if (opts.transportFactoryOverride) return {
338
+ transport: opts.transportFactoryOverride(cfg, void 0, provider),
339
+ provider
340
+ };
341
+ return {
342
+ transport: createHttpTransport({
343
+ name: cfg.name,
344
+ url: cfg.url,
345
+ authProvider: provider
346
+ }),
347
+ provider
348
+ };
349
+ }
350
+ if (cfg.auth.mode === `clientCredentials`) {
351
+ if (!cfg.auth.clientId || !cfg.auth.clientSecret) return { error: makeError(`auth_unavailable`, `clientCredentials mode requires auth.clientId and auth.clientSecret inline for ${cfg.name}`) };
352
+ const ccProvider = createClientCredentialsProvider({
353
+ tokenUrl: cfg.auth.tokenUrl,
354
+ clientId: cfg.auth.clientId,
355
+ clientSecret: cfg.auth.clientSecret,
356
+ scopes: cfg.auth.scopes,
357
+ audience: cfg.auth.audience,
358
+ resource: cfg.auth.resource
359
+ });
360
+ if (opts.transportFactoryOverride) return { transport: opts.transportFactoryOverride(cfg, void 0, void 0) };
361
+ return { transport: createHttpTransport({
362
+ name: cfg.name,
363
+ url: cfg.url,
364
+ authProvider: ccProvider
365
+ }) };
366
+ }
367
+ return { error: makeError(`auth_unavailable`, `auth.mode=${cfg.auth.mode} not implemented`) };
368
+ };
369
+ const connectAndList = async (entry, provider) => {
370
+ if (!entry.transport) return {
371
+ state: `error`,
372
+ id: entry.config.name,
373
+ error: entry.error ?? makeError(`transport_error`, `no transport`)
374
+ };
375
+ try {
376
+ await entry.transport.connect();
377
+ const out = await entry.transport.client.listTools();
378
+ entry.tools = out.tools.map((t) => ({
379
+ name: t.name,
380
+ description: t.description,
381
+ inputSchema: t.inputSchema
382
+ }));
383
+ entry.capabilities = entry.transport.client.getServerCapabilities?.();
384
+ entry.status = `ready`;
385
+ notify();
386
+ return {
387
+ state: `ready`,
388
+ id: entry.config.name,
389
+ toolCount: entry.tools.length
390
+ };
391
+ } catch (err) {
392
+ const authUrl = provider?.peekAuthUrl();
393
+ if (authUrl) {
394
+ entry.status = `authenticating`;
395
+ entry.authUrl = authUrl;
396
+ entry.provider = provider;
397
+ notify();
398
+ try {
399
+ opts.openAuthorizeUrl?.(authUrl, entry.config.name);
400
+ } catch {}
401
+ return {
402
+ state: `authenticating`,
403
+ id: entry.config.name,
404
+ authUrl
405
+ };
406
+ }
407
+ entry.status = `error`;
408
+ const e = makeError(`transport_error`, err.message);
409
+ entry.error = e;
410
+ notify();
411
+ return {
412
+ state: `error`,
413
+ id: entry.config.name,
414
+ error: e
415
+ };
416
+ }
417
+ };
418
+ let seq = 0;
419
+ const subscribers = new Set();
420
+ const snapshot = () => ({
421
+ seq: ++seq,
422
+ servers: registry.list()
423
+ });
424
+ const notify = () => {
425
+ if (subscribers.size === 0) return;
426
+ const snap = snapshot();
427
+ for (const h of subscribers) try {
428
+ h(snap);
429
+ } catch {}
430
+ };
431
+ const registry = {
432
+ subscribe(handler) {
433
+ subscribers.add(handler);
434
+ try {
435
+ handler({
436
+ seq: 0,
437
+ servers: registry.list()
438
+ });
439
+ } catch {}
440
+ return () => {
441
+ subscribers.delete(handler);
442
+ };
443
+ },
444
+ async addServer(cfg) {
445
+ const existing = entries.get(cfg.name);
446
+ const hash = hashConfig(cfg);
447
+ if (existing && existing.configHash === hash && existing.status === `ready`) return {
448
+ state: `ready`,
449
+ id: cfg.name,
450
+ toolCount: existing.tools.length
451
+ };
452
+ if (existing) await Promise.resolve(existing.transport?.close()).catch(() => {});
453
+ if (cfg.auth?.mode === `authorizationCode`) {
454
+ if (cfg.auth.tokens) authStore.seedTokens(cfg.name, cfg.auth.tokens);
455
+ if (cfg.auth.client) authStore.seedClient(cfg.name, cfg.auth.client);
456
+ authStore.registerHooks(cfg.name, {
457
+ onTokensChanged: cfg.auth.onTokensChanged,
458
+ onClientRegistered: cfg.auth.onClientRegistered
459
+ });
460
+ }
461
+ const built = await buildTransport(cfg);
462
+ const entry = {
463
+ config: cfg,
464
+ configHash: hash,
465
+ status: built.transport ? `connecting` : `error`,
466
+ transport: built.transport,
467
+ error: built.error,
468
+ authUrl: built.authUrl,
469
+ tools: [],
470
+ provider: built.provider
471
+ };
472
+ entries.set(cfg.name, entry);
473
+ if (built.error) {
474
+ notify();
475
+ return {
476
+ state: `error`,
477
+ id: cfg.name,
478
+ error: built.error
479
+ };
480
+ }
481
+ if (built.authUrl) {
482
+ entry.status = `authenticating`;
483
+ notify();
484
+ try {
485
+ opts.openAuthorizeUrl?.(built.authUrl, cfg.name);
486
+ } catch {}
487
+ return {
488
+ state: `authenticating`,
489
+ id: cfg.name,
490
+ authUrl: built.authUrl
491
+ };
492
+ }
493
+ return await connectAndList(entry, built.provider);
494
+ },
495
+ async applyConfig(cfg) {
496
+ const seen = new Set(cfg.servers.map((s) => s.name));
497
+ const results = [];
498
+ for (const s of cfg.servers) results.push(await registry.addServer(s));
499
+ for (const name of [...entries.keys()]) if (!seen.has(name)) await registry.removeServer(name);
500
+ return results;
501
+ },
502
+ async removeServer(name) {
503
+ const e = entries.get(name);
504
+ if (!e) return;
505
+ await Promise.resolve(e.transport?.close()).catch(() => {});
506
+ entries.delete(name);
507
+ authStore.forget(name);
508
+ notify();
509
+ },
510
+ list() {
511
+ return [...entries.values()].map((e) => ({
512
+ name: e.config.name,
513
+ status: e.status,
514
+ toolCount: e.tools.length,
515
+ transport: e.config.transport,
516
+ authMode: e.config.auth?.mode,
517
+ authUrl: e.authUrl,
518
+ error: e.error,
519
+ tools: e.tools,
520
+ capabilities: e.capabilities
521
+ }));
522
+ },
523
+ get(name) {
524
+ return entries.get(name);
525
+ },
526
+ async finishAuth(serverName, code, _state) {
527
+ const e = entries.get(serverName);
528
+ if (!e) throw new Error(`unknown server "${serverName}"`);
529
+ const provider = e.provider;
530
+ if (!provider) throw new Error(`server "${serverName}" has no OAuth provider`);
531
+ const serverUrl = e.config.url;
532
+ if (!serverUrl) throw new Error(`server "${serverName}" has no URL — cannot complete token exchange`);
533
+ const { auth } = await import(`@modelcontextprotocol/sdk/client/auth.js`);
534
+ await auth(provider, {
535
+ serverUrl,
536
+ authorizationCode: code
537
+ });
538
+ provider.clearAuthUrl();
539
+ return await registry.addServer(e.config);
540
+ },
541
+ async disable(name) {
542
+ const e = entries.get(name);
543
+ if (!e) throw new Error(`unknown server "${name}"`);
544
+ await Promise.resolve(e.transport?.close()).catch(() => {});
545
+ e.transport = void 0;
546
+ e.tools = [];
547
+ e.authUrl = void 0;
548
+ e.status = `disabled`;
549
+ e.error = void 0;
550
+ notify();
551
+ },
552
+ async enable(name) {
553
+ const e = entries.get(name);
554
+ if (!e) throw new Error(`unknown server "${name}"`);
555
+ if (e.status !== `disabled`) return {
556
+ state: `ready`,
557
+ id: name,
558
+ toolCount: e.tools.length
559
+ };
560
+ return await registry.addServer(e.config);
561
+ },
562
+ async reauthorize(name) {
563
+ const e = entries.get(name);
564
+ if (!e) return;
565
+ if (e.config.auth?.mode !== `authorizationCode`) return;
566
+ if (e.status === `disabled`) return;
567
+ await Promise.resolve(e.transport?.close()).catch(() => {});
568
+ authStore.clearCredentials(name);
569
+ e.status = `connecting`;
570
+ e.tools = [];
571
+ e.capabilities = void 0;
572
+ e.authUrl = void 0;
573
+ e.error = void 0;
574
+ notify();
575
+ const built = await buildTransport(e.config);
576
+ e.transport = built.transport;
577
+ e.error = built.error;
578
+ e.authUrl = built.authUrl;
579
+ e.provider = built.provider;
580
+ if (built.error || !built.transport) {
581
+ e.status = `error`;
582
+ notify();
583
+ return;
584
+ }
585
+ if (built.authUrl) {
586
+ e.status = `authenticating`;
587
+ notify();
588
+ try {
589
+ opts.openAuthorizeUrl?.(built.authUrl, name);
590
+ } catch {}
591
+ return;
592
+ }
593
+ await connectAndList(e, built.provider);
594
+ },
595
+ async close() {
596
+ const transports = [...entries.values()].map((e) => e.transport).filter((t) => Boolean(t));
597
+ await Promise.all(transports.map((t) => Promise.resolve(t.close()).catch(() => {})));
598
+ for (const name of [...entries.keys()]) authStore.forget(name);
599
+ entries.clear();
600
+ notify();
601
+ }
602
+ };
603
+ return registry;
604
+ }
605
+
606
+ //#endregion
607
+ //#region src/config/env-expand.ts
608
+ const RE = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
609
+ function expandString(s, env) {
610
+ const missing = [];
611
+ const value = s.replace(RE, (_, name) => {
612
+ const v = env[name];
613
+ if (v === void 0) {
614
+ missing.push(name);
615
+ return ``;
616
+ }
617
+ return v;
618
+ });
619
+ return {
620
+ value,
621
+ missing
622
+ };
623
+ }
624
+ function expandEnv(s, env = process.env) {
625
+ return expandString(s, env).value;
626
+ }
627
+ expandEnv.detailed = (s, env = process.env) => expandString(s, env);
628
+ expandEnv.deep = function deep(input, env = process.env) {
629
+ if (typeof input === `string`) return expandString(input, env).value;
630
+ if (Array.isArray(input)) return input.map((x) => deep(x, env));
631
+ if (input && typeof input === `object`) {
632
+ const out = {};
633
+ for (const [k, v] of Object.entries(input)) out[k] = deep(v, env);
634
+ return out;
635
+ }
636
+ return input;
637
+ };
638
+
639
+ //#endregion
640
+ //#region src/config/loader.ts
641
+ const KNOWN_AUTH_MODES = new Set([
642
+ `none`,
643
+ `apiKey`,
644
+ `clientCredentials`,
645
+ `authorizationCode`
646
+ ]);
647
+ const FORBIDDEN_REF_KEYS = [
648
+ `valueRef`,
649
+ `clientIdRef`,
650
+ `clientSecretRef`
651
+ ];
652
+ function fail(msg) {
653
+ throw new Error(`mcp.json: ${msg}`);
654
+ }
655
+ function parseConfig(raw, env = process.env) {
656
+ if (!raw || typeof raw !== `object`) fail(`not an object`);
657
+ const top = Object.keys(raw);
658
+ for (const k of top) if (k !== `servers`) fail(`unknown top-level field "${k}"`);
659
+ const serversObj = raw.servers;
660
+ if (!serversObj || typeof serversObj !== `object`) fail(`missing "servers" object`);
661
+ const servers = [];
662
+ for (const [name, entry] of Object.entries(serversObj)) {
663
+ if (!entry || typeof entry !== `object`) fail(`server "${name}" not an object`);
664
+ const e = entry;
665
+ if (e.transport !== `http` && e.transport !== `stdio`) fail(`server "${name}" transport must be 'http' or 'stdio'`);
666
+ const auth = e.auth ?? { mode: `none` };
667
+ if (typeof auth.mode !== `string` || !KNOWN_AUTH_MODES.has(auth.mode)) fail(`server "${name}" auth.mode invalid`);
668
+ for (const k of FORBIDDEN_REF_KEYS) if (k in auth) fail(`server "${name}" uses forbidden "${k}" — secrets are not configured in mcp.json (pass them inline on the auth config at the call site)`);
669
+ if (e.transport === `http`) {
670
+ if (typeof e.url !== `string`) fail(`server "${name}" missing url`);
671
+ servers.push({
672
+ name,
673
+ transport: `http`,
674
+ url: expandEnv(e.url, env),
675
+ auth: expandEnv.deep(auth, env),
676
+ timeoutMs: typeof e.timeoutMs === `number` ? e.timeoutMs : void 0
677
+ });
678
+ } else {
679
+ if (typeof e.command !== `string`) fail(`server "${name}" missing command`);
680
+ const args = Array.isArray(e.args) ? e.args.map((a) => expandEnv(String(a), env)) : [];
681
+ servers.push({
682
+ name,
683
+ transport: `stdio`,
684
+ command: expandEnv(e.command, env),
685
+ args,
686
+ env: e.env && typeof e.env === `object` ? Object.fromEntries(Object.entries(e.env).map(([k, v]) => [k, expandEnv(String(v), env)])) : void 0,
687
+ auth: expandEnv.deep(auth, env),
688
+ timeoutMs: typeof e.timeoutMs === `number` ? e.timeoutMs : void 0
689
+ });
690
+ }
691
+ }
692
+ return {
693
+ servers,
694
+ raw
695
+ };
696
+ }
697
+ async function loadConfig(path$1, env = process.env) {
698
+ const text = await fs.readFile(path$1, `utf-8`);
699
+ return parseConfig(JSON.parse(text), env);
700
+ }
701
+
702
+ //#endregion
703
+ //#region src/config/watcher.ts
704
+ /**
705
+ * Start watching `path` for changes. Each modification triggers
706
+ * `loadConfig(path)` (debounced) and forwards the parsed config to
707
+ * `onChange`, or any error to `onError`. The caller is responsible
708
+ * for performing the initial load — `watchConfig` only sets up the
709
+ * subscription so the caller can fully await its first apply before
710
+ * subsequent change events start firing.
711
+ */
712
+ async function watchConfig(path$1, opts) {
713
+ const debounce = opts.debounceMs ?? 200;
714
+ let timer;
715
+ const reload = async () => {
716
+ try {
717
+ const cfg = await loadConfig(path$1, opts.env);
718
+ opts.onChange(cfg);
719
+ } catch (err) {
720
+ opts.onError?.(err);
721
+ }
722
+ };
723
+ const watcher = fs$1.watch(path$1, () => {
724
+ if (timer) clearTimeout(timer);
725
+ timer = setTimeout(reload, debounce);
726
+ });
727
+ return () => {
728
+ if (timer) clearTimeout(timer);
729
+ watcher.close();
730
+ };
731
+ }
732
+
733
+ //#endregion
734
+ //#region src/transports/timeout.ts
735
+ const DEFAULT_TIMEOUT_MS = 3e4;
736
+ var TimeoutError = class extends Error {
737
+ kind = `timeout`;
738
+ constructor(ms) {
739
+ super(`MCP tool call timed out after ${ms}ms`);
740
+ }
741
+ };
742
+ function withTimeout(p, ms) {
743
+ let timer;
744
+ const guard = new Promise((_, reject) => {
745
+ timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
746
+ });
747
+ return Promise.race([p, guard]).finally(() => {
748
+ if (timer) clearTimeout(timer);
749
+ });
750
+ }
751
+
752
+ //#endregion
753
+ //#region src/bridge/tool-bridge.ts
754
+ const PREFIX = `mcp`;
755
+ const MAX_LEN = 128;
756
+ function sanitize(name) {
757
+ return name.replace(/[^A-Za-z0-9_-]/g, `_`);
758
+ }
759
+ function prefixToolName(server, tool) {
760
+ const full = `${PREFIX}__${sanitize(server)}__${sanitize(tool)}`;
761
+ return full.length > MAX_LEN ? full.slice(0, MAX_LEN) : full;
762
+ }
763
+ /**
764
+ * Coerce an MCP tool's inputSchema into a shape downstream LLM adapters can
765
+ * consume safely. Some servers send `{ type: 'object' }` with no `properties`
766
+ * for no-arg tools; pi-agent-core walks `inputSchema.properties` and crashes on
767
+ * undefined. We default `properties` to `{}` and `required` to `[]` for object
768
+ * schemas; non-object schemas pass through unchanged.
769
+ */
770
+ function normalizeInputSchema(schema) {
771
+ if (!schema || typeof schema !== `object`) return {
772
+ type: `object`,
773
+ properties: {},
774
+ required: []
775
+ };
776
+ const s = schema;
777
+ if (s.type !== `object`) return schema;
778
+ if (s.properties && typeof s.properties === `object`) return schema;
779
+ return {
780
+ ...s,
781
+ properties: {},
782
+ required: Array.isArray(s.required) ? s.required : []
783
+ };
784
+ }
785
+ /**
786
+ * Build a BridgedTool from a synthetic (non-MCP-server-backed) call. Used by
787
+ * the resource and prompt bridges. Caller supplies the JSON schema directly.
788
+ */
789
+ function makeSyntheticBridgedTool(opts) {
790
+ return {
791
+ name: opts.name,
792
+ server: opts.server,
793
+ description: opts.description,
794
+ inputSchema: opts.schema,
795
+ parameters: opts.schema,
796
+ label: opts.label,
797
+ async call(args) {
798
+ return await opts.run(args);
799
+ },
800
+ async execute(_toolCallId, params, signal) {
801
+ const result = await opts.run(params, signal);
802
+ return {
803
+ content: Array.isArray(result?.content) ? result.content : [],
804
+ details: result
805
+ };
806
+ }
807
+ };
808
+ }
809
+ function bridgeMcpTool(opts) {
810
+ const name = prefixToolName(opts.server, opts.tool.name);
811
+ const schema = normalizeInputSchema(opts.tool.inputSchema);
812
+ const ms = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
813
+ const invoke = async (args, extra) => {
814
+ const onProgress = extra?.onProgress ?? opts.onProgress;
815
+ const signal = extra?.signal ?? opts.signal;
816
+ const callArgs = onProgress !== void 0 || signal !== void 0 ? [
817
+ {
818
+ name: opts.tool.name,
819
+ arguments: args
820
+ },
821
+ void 0,
822
+ {
823
+ onProgress,
824
+ signal
825
+ }
826
+ ] : [{
827
+ name: opts.tool.name,
828
+ arguments: args
829
+ }];
830
+ return await withTimeout(opts.client.callTool(...callArgs), ms);
831
+ };
832
+ return {
833
+ name,
834
+ server: opts.server,
835
+ description: opts.tool.description,
836
+ inputSchema: schema,
837
+ parameters: schema,
838
+ label: opts.tool.name,
839
+ async call(args) {
840
+ try {
841
+ return await invoke(args);
842
+ } catch (err) {
843
+ const e = err;
844
+ if (e.kind === `timeout`) throw err;
845
+ const wrapped = {
846
+ kind: `transport_error`,
847
+ message: e.message ?? String(err)
848
+ };
849
+ throw wrapped;
850
+ }
851
+ },
852
+ async execute(_toolCallId, params, signal) {
853
+ const result = await invoke(params, { signal });
854
+ return {
855
+ content: Array.isArray(result?.content) ? result.content : [],
856
+ details: result
857
+ };
858
+ }
859
+ };
860
+ }
861
+
862
+ //#endregion
863
+ //#region src/bridge/resource-bridge.ts
864
+ function buildResourceTools(opts) {
865
+ const ms = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
866
+ return [makeSyntheticBridgedTool({
867
+ name: prefixToolName(opts.server, `list_resources`),
868
+ server: opts.server,
869
+ label: `list_resources`,
870
+ description: `List resources on ${opts.server}`,
871
+ schema: {
872
+ type: `object`,
873
+ properties: {},
874
+ required: []
875
+ },
876
+ run: () => withTimeout(opts.client.listResources(), ms)
877
+ }), makeSyntheticBridgedTool({
878
+ name: prefixToolName(opts.server, `read_resource`),
879
+ server: opts.server,
880
+ label: `read_resource`,
881
+ description: `Read a resource from ${opts.server}`,
882
+ schema: {
883
+ type: `object`,
884
+ properties: { uri: { type: `string` } },
885
+ required: [`uri`]
886
+ },
887
+ run: (args) => withTimeout(opts.client.readResource(args), ms)
888
+ })];
889
+ }
890
+
891
+ //#endregion
892
+ //#region src/bridge/prompt-bridge.ts
893
+ function buildPromptTools(opts) {
894
+ const ms = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
895
+ return [makeSyntheticBridgedTool({
896
+ name: prefixToolName(opts.server, `list_prompts`),
897
+ server: opts.server,
898
+ label: `list_prompts`,
899
+ description: `List prompts on ${opts.server}`,
900
+ schema: {
901
+ type: `object`,
902
+ properties: {},
903
+ required: []
904
+ },
905
+ run: () => withTimeout(opts.client.listPrompts(), ms)
906
+ }), makeSyntheticBridgedTool({
907
+ name: prefixToolName(opts.server, `get_prompt`),
908
+ server: opts.server,
909
+ label: `get_prompt`,
910
+ description: `Get a prompt template from ${opts.server}`,
911
+ schema: {
912
+ type: `object`,
913
+ properties: {
914
+ name: { type: `string` },
915
+ arguments: {
916
+ type: `object`,
917
+ additionalProperties: true
918
+ }
919
+ },
920
+ required: [`name`]
921
+ },
922
+ run: (args) => withTimeout(opts.client.getPrompt(args), ms)
923
+ })];
924
+ }
925
+
926
+ //#endregion
927
+ //#region src/persistence/keychain.ts
928
+ async function run(cmd, args, stdin) {
929
+ return new Promise((resolve) => {
930
+ const proc = spawn(cmd, args, { stdio: [
931
+ `pipe`,
932
+ `pipe`,
933
+ `pipe`
934
+ ] });
935
+ const out = [];
936
+ const err = [];
937
+ proc.stdout.on(`data`, (b) => {
938
+ out.push(b);
939
+ });
940
+ proc.stderr.on(`data`, (b) => {
941
+ err.push(b);
942
+ });
943
+ proc.on(`error`, (e) => {
944
+ resolve({
945
+ stdout: ``,
946
+ stderr: e.message,
947
+ code: -1
948
+ });
949
+ });
950
+ proc.on(`close`, (code) => {
951
+ resolve({
952
+ stdout: Buffer.concat(out).toString(`utf8`),
953
+ stderr: Buffer.concat(err).toString(`utf8`),
954
+ code
955
+ });
956
+ });
957
+ if (stdin != null) proc.stdin.end(stdin);
958
+ else proc.stdin.end();
959
+ });
960
+ }
961
+ const macosBackend = {
962
+ async get(service, account) {
963
+ const r = await run(`security`, [
964
+ `find-generic-password`,
965
+ `-s`,
966
+ service,
967
+ `-a`,
968
+ account,
969
+ `-w`
970
+ ]);
971
+ if (r.code === 0) return r.stdout.replace(/\n$/, ``);
972
+ if (/could not be found/i.test(r.stderr)) return void 0;
973
+ throw new Error(`security find-generic-password failed: ${r.stderr.trim()}`);
974
+ },
975
+ async set(service, account, value) {
976
+ const r = await run(`security`, [
977
+ `add-generic-password`,
978
+ `-s`,
979
+ service,
980
+ `-a`,
981
+ account,
982
+ `-w`,
983
+ value,
984
+ `-U`
985
+ ]);
986
+ if (r.code !== 0) throw new Error(`security add-generic-password failed: ${r.stderr.trim()}`);
987
+ }
988
+ };
989
+ const linuxBackend = {
990
+ async get(service, account) {
991
+ const r = await run(`secret-tool`, [
992
+ `lookup`,
993
+ `service`,
994
+ service,
995
+ `account`,
996
+ account
997
+ ]);
998
+ if (r.code === 0) return r.stdout.replace(/\n$/, ``);
999
+ if (r.code === 1 && r.stderr.trim() === ``) return void 0;
1000
+ throw new Error(`secret-tool lookup failed: ${r.stderr.trim()}`);
1001
+ },
1002
+ async set(service, account, value) {
1003
+ const r = await run(`secret-tool`, [
1004
+ `store`,
1005
+ `--label`,
1006
+ `${service}/${account}`,
1007
+ `service`,
1008
+ service,
1009
+ `account`,
1010
+ account
1011
+ ], value);
1012
+ if (r.code !== 0) throw new Error(`secret-tool store failed: ${r.stderr.trim()}`);
1013
+ }
1014
+ };
1015
+ function pickBackend() {
1016
+ if (process.platform === `darwin`) return macosBackend;
1017
+ if (process.platform === `linux`) return linuxBackend;
1018
+ return void 0;
1019
+ }
1020
+ /**
1021
+ * Opt-in helper for OAuth-mode `auth` configs. Loads any persisted tokens
1022
+ * and DCR client info from the OS keychain on startup, and returns the
1023
+ * matching `onTokensChanged` / `onClientRegistered` callbacks so the SDK
1024
+ * writes refreshed material back.
1025
+ *
1026
+ * const honeycomb = await keychainPersistence({ server: 'honeycomb' })
1027
+ * await mcpRegistry.addServer({
1028
+ * name: 'honeycomb',
1029
+ * transport: 'http',
1030
+ * url: 'https://mcp.honeycomb.io/mcp',
1031
+ * auth: {
1032
+ * mode: 'authorizationCode',
1033
+ * flow: 'browser',
1034
+ * scopes: ['mcp:read'],
1035
+ * ...honeycomb,
1036
+ * },
1037
+ * })
1038
+ *
1039
+ * Backend is chosen by `process.platform`:
1040
+ * - darwin → `/usr/bin/security` (no extra deps)
1041
+ * - linux → `secret-tool` from libsecret-tools (apt: libsecret-tools)
1042
+ * - win32 → not implemented yet — falls back to no-op callbacks
1043
+ *
1044
+ * If the chosen CLI isn't installed (e.g. minimal Linux container without
1045
+ * libsecret), reads/writes throw on first use; the registry surfaces
1046
+ * that as a connect-time error and the OAuth flow continues without
1047
+ * persistence.
1048
+ */
1049
+ async function keychainPersistence(opts) {
1050
+ const service = opts.service ?? `electric-agents`;
1051
+ const backend = opts.backend ?? pickBackend();
1052
+ if (!backend) {
1053
+ console.warn(`[agents-mcp] keychainPersistence: ${process.platform} not supported yet — ${opts.server} OAuth tokens will not persist`);
1054
+ return {
1055
+ onTokensChanged: async () => {},
1056
+ onClientRegistered: async () => {}
1057
+ };
1058
+ }
1059
+ const [tokensRaw, clientRaw] = await Promise.all([backend.get(service, `tokens:${opts.server}`), backend.get(service, `client:${opts.server}`)]);
1060
+ return {
1061
+ tokens: tokensRaw ? JSON.parse(tokensRaw) : void 0,
1062
+ client: clientRaw ? JSON.parse(clientRaw) : void 0,
1063
+ onTokensChanged: async (t) => {
1064
+ await backend.set(service, `tokens:${opts.server}`, JSON.stringify(t));
1065
+ },
1066
+ onClientRegistered: async (c) => {
1067
+ await backend.set(service, `client:${opts.server}`, JSON.stringify(c));
1068
+ }
1069
+ };
1070
+ }
1071
+
1072
+ //#endregion
1073
+ //#region src/persistence/file.ts
1074
+ async function readSafe(file) {
1075
+ try {
1076
+ const stat = await fs.stat(file);
1077
+ if ((stat.mode & 511) !== 384) throw new Error(`${file} has permissions ${(stat.mode & 511).toString(8)}; refusing to read (require 0600).`);
1078
+ const text = await fs.readFile(file, `utf-8`);
1079
+ return text.trim() ? JSON.parse(text) : {};
1080
+ } catch (err) {
1081
+ if (err.code === `ENOENT`) return {};
1082
+ throw err;
1083
+ }
1084
+ }
1085
+ async function writeSafe(file, data) {
1086
+ await fs.mkdir(path.dirname(file), { recursive: true });
1087
+ const tmp = `${file}.tmp`;
1088
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode: 384 });
1089
+ await fs.rename(tmp, file);
1090
+ }
1091
+ /**
1092
+ * Opt-in helper for OAuth-mode `auth` configs. Mirrors `keychainPersistence`
1093
+ * but persists to a JSON file on disk (mode 0600). Right tool when no OS
1094
+ * keychain is available — CI runners, minimal Linux containers, etc.
1095
+ *
1096
+ * const honeycomb = await filePersistence({
1097
+ * path: './.electric-agents/credentials.json',
1098
+ * server: 'honeycomb',
1099
+ * })
1100
+ * await mcpRegistry.addServer({ ..., auth: { ..., ...honeycomb } })
1101
+ */
1102
+ async function filePersistence(opts) {
1103
+ const data = await readSafe(opts.path);
1104
+ return {
1105
+ tokens: data.tokens?.[opts.server],
1106
+ client: data.client?.[opts.server],
1107
+ onTokensChanged: async (t) => {
1108
+ const cur = await readSafe(opts.path);
1109
+ cur.tokens = {
1110
+ ...cur.tokens ?? {},
1111
+ [opts.server]: t
1112
+ };
1113
+ await writeSafe(opts.path, cur);
1114
+ },
1115
+ onClientRegistered: async (c) => {
1116
+ const cur = await readSafe(opts.path);
1117
+ cur.client = {
1118
+ ...cur.client ?? {},
1119
+ [opts.server]: c
1120
+ };
1121
+ await writeSafe(opts.path, cur);
1122
+ }
1123
+ };
1124
+ }
1125
+
1126
+ //#endregion
1127
+ //#region src/index.ts
1128
+ const VERSION = `0.1.0`;
1129
+
1130
+ //#endregion
1131
+ export { MCP_TOOLS_SENTINEL, VERSION, bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, filePersistence, filterByAllowlist, isMcpToolsSentinel, keychainPersistence, loadConfig, mcp, parseConfig, prefixToolName, watchConfig };