@cldmv/gh-broker 0.7.0 → 0.9.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 (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
package/src/secrets.ts DELETED
@@ -1,137 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/secrets.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { readFileSync } from "node:fs";
17
- import { join } from "node:path";
18
- import type { Config, SecretStore } from "./types.ts";
19
- import { resolveSecretsPath } from "./paths.ts";
20
-
21
- const CRED_PREFIX = "cred:";
22
-
23
- /**
24
- * Loads PATs from the configured secret store and returns a resolver mapping
25
- * "cred:<name>" refs to token values. Never writes or logs a token.
26
- *
27
- * Provider seam (chosen at install time, recorded in config.secrets.provider):
28
- * - "systemd-creds" (default) — tokens are systemd-encrypted, host/TPM-bound
29
- * `.cred` files delivered via LoadCredentialEncrypted into $CREDENTIALS_DIRECTORY
30
- * (memory-only, service-user-readable). Encrypted at rest; not portable.
31
- * - "file" — a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path.
32
- * Plaintext at rest (perms only); portable and platform-neutral.
33
- *
34
- * Both keep tokens off any agent-readable path — the agent boundary is filesystem
35
- * perms either way. See docs/deploy-architecture.md.
36
- */
37
- export function loadSecretStore(config: Config): SecretStore {
38
- const provider = config.secrets?.provider ?? "systemd-creds";
39
- switch (provider) {
40
- case "systemd-creds":
41
- return systemdCredsStore(config);
42
- case "file":
43
- return fileStore(config);
44
- default:
45
- throw new Error(`secrets: unknown provider "${provider}" (expected "systemd-creds" or "file")`);
46
- }
47
- }
48
-
49
- const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
50
-
51
- /**
52
- * Credential names become filesystem path components (`join(dir, name)`) and are
53
- * interpolated into systemd drop-in lines, so constrain them to a safe charset —
54
- * no path separators, "..", colons, or control characters — to prevent reads
55
- * outside $CREDENTIALS_DIRECTORY and malformed drop-ins.
56
- */
57
- function assertSafeName(name: string): string {
58
- if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
59
- throw new Error(`secrets: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
60
- }
61
- return name;
62
- }
63
-
64
- function credName(ref: string): string {
65
- if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: bad credential ref "${ref}"`);
66
- return assertSafeName(ref.slice(CRED_PREFIX.length));
67
- }
68
-
69
- /** Eagerly read every referenced credential so a missing token fails at startup, not on first request. */
70
- function validateRefs(config: Config, read: (name: string) => string): void {
71
- for (const [owner, creds] of Object.entries(config.owners)) {
72
- for (const ref of [creds.read, creds.write]) {
73
- if (!ref.startsWith(CRED_PREFIX)) throw new Error(`secrets: owner "${owner}" ref must be "cred:<name>", got "${ref}"`);
74
- read(assertSafeName(ref.slice(CRED_PREFIX.length)));
75
- }
76
- }
77
- if (config.app) {
78
- // The App key is a secret too — it must be a cred: ref, never inlined in the
79
- // token-free config. Reject a non-cred ref rather than silently accepting it.
80
- if (!config.app.privateKey.startsWith(CRED_PREFIX)) {
81
- throw new Error(`secrets: config.app.privateKey must be a "cred:<name>" ref, got "${config.app.privateKey}"`);
82
- }
83
- read(assertSafeName(config.app.privateKey.slice(CRED_PREFIX.length)));
84
- }
85
- }
86
-
87
- /** systemd-creds provider: read decrypted creds from $CREDENTIALS_DIRECTORY. */
88
- function systemdCredsStore(config: Config): SecretStore {
89
- const dir = process.env.CREDENTIALS_DIRECTORY;
90
- if (!dir) {
91
- throw new Error("secrets: $CREDENTIALS_DIRECTORY unset — the systemd-creds provider needs LoadCredentialEncrypted= (run under systemd)");
92
- }
93
- const cache = new Map<string, string>();
94
- const read = (name: string): string => {
95
- const cached = cache.get(name);
96
- if (cached !== undefined) return cached;
97
- let val: string;
98
- try {
99
- val = readFileSync(join(dir, name), "utf8").replace(/\r?\n$/, "");
100
- } catch (err) {
101
- throw new Error(`secrets: credential "${name}" not available: ${(err as Error).message}`);
102
- }
103
- if (!val) throw new Error(`secrets: credential "${name}" is empty`);
104
- cache.set(name, val);
105
- return val;
106
- };
107
- validateRefs(config, read);
108
- return { resolve: (ref) => read(credName(ref)) };
109
- }
110
-
111
- /** file provider: read tokens from a JSON `{ "<name>": "<token>" }` map. */
112
- function fileStore(config: Config): SecretStore {
113
- const path = resolveSecretsPath(config.secrets?.path);
114
- let raw: unknown;
115
- try {
116
- raw = JSON.parse(readFileSync(path, "utf8"));
117
- } catch (err) {
118
- throw new Error(`secrets: cannot read/parse secrets file ${path}: ${(err as Error).message}`);
119
- }
120
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
121
- throw new Error(`secrets: ${path} must be a JSON object of { "<name>": "<token>" }`);
122
- }
123
- const map = raw as Record<string, unknown>; // read once into memory; never re-read or write
124
-
125
- const read = (name: string): string => {
126
- const val = map[name];
127
- if (typeof val !== "string") throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
128
- // Normalize like the systemd-creds provider: strip a single trailing newline
129
- // (common when a token is copy/pasted into the JSON) so it isn't silently
130
- // mismatched at auth time. A value that is only a newline then reads as empty.
131
- const token = val.replace(/\r?\n$/, "");
132
- if (token.length === 0) throw new Error(`secrets: credential "${name}" missing or empty in ${path}`);
133
- return token;
134
- };
135
- validateRefs(config, read);
136
- return { resolve: (ref) => read(credName(ref)) };
137
- }
package/src/server.ts DELETED
@@ -1,91 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/server.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { createServer, type Socket } from "node:net";
17
- import { chmodSync, chownSync, existsSync, readFileSync, unlinkSync } from "node:fs";
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
- import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js";
21
- import type { Config } from "./types.ts";
22
- import type { Router } from "./router.ts";
23
-
24
- const SERVER_INFO = { name: "gh-broker", version: "0.1.0" };
25
-
26
- function socketPath(listen: string): string {
27
- if (!listen.startsWith("unix:")) throw new Error(`server: unsupported listen "${listen}"`);
28
- return listen.slice("unix:".length);
29
- }
30
-
31
- /** Resolve a group name → gid via /etc/group (no native Node API for this). */
32
- function groupGid(name: string): number | undefined {
33
- try {
34
- for (const line of readFileSync("/etc/group", "utf8").split("\n")) {
35
- const parts = line.split(":");
36
- if (parts[0] === name && parts[2]) return Number(parts[2]);
37
- }
38
- } catch {
39
- /* ignore — fall back to RuntimeDirectory perms */
40
- }
41
- return undefined;
42
- }
43
-
44
- /**
45
- * Each agent connection (a socat stdio bridge to our Unix socket) gets its own MCP
46
- * Server over a StdioServerTransport bound to that socket. `tools/list` returns the
47
- * pre-built catalog; `tools/call` delegates to the router (allowlist + owner/mode →
48
- * PAT + forward/dispatch). The token never crosses to this side.
49
- */
50
- function handleConnection(socket: Socket, router: Router, catalog: Tool[]): void {
51
- const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
52
-
53
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalog }));
54
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
55
- const args = (req.params.arguments ?? {}) as Record<string, unknown>;
56
- return router.call(req.params.name, args);
57
- });
58
-
59
- const transport = new StdioServerTransport(socket, socket);
60
- server.connect(transport).catch((err: unknown) => {
61
- console.error(`gh-broker: connection error: ${(err as Error).message}`);
62
- socket.destroy();
63
- });
64
- socket.on("close", () => void server.close().catch(() => {}));
65
- socket.on("error", () => {
66
- /* client hangup — ignore */
67
- });
68
- }
69
-
70
- export function startServer(config: Config, router: Router, catalog: Tool[]): Promise<void> {
71
- const path = socketPath(config.listen);
72
- if (existsSync(path)) unlinkSync(path); // clear a stale socket from a prior run
73
-
74
- return new Promise((resolve, reject) => {
75
- const server = createServer((sock: Socket) => handleConnection(sock, router, catalog));
76
- server.on("error", reject);
77
- server.listen(path, () => {
78
- // Restrict the socket to the agent-clients group: chgrp to gh-broker-clients,
79
- // mode 0660 (owner+group rw). The agent user is a member of that group.
80
- const group = process.env.GH_BROKER_SOCKET_GROUP ?? "gh-broker-clients";
81
- const gid = groupGid(group);
82
- try {
83
- if (gid !== undefined) chownSync(path, process.getuid?.() ?? -1, gid);
84
- chmodSync(path, 0o660);
85
- } catch {
86
- /* best-effort; RuntimeDirectory perms already restrict access */
87
- }
88
- resolve();
89
- });
90
- });
91
- }
package/src/types.ts DELETED
@@ -1,104 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/types.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- /**
17
- * Config + internal types for gh-broker.
18
- *
19
- * The on-disk config holds no secrets — only the tool allowlist and, per owner,
20
- * the *names* of the credentials to use. Actual PAT values are resolved at
21
- * runtime from a separate, agent-unreadable secrets store (see secrets.ts and
22
- * docs/deploy-architecture.md). Keeping the config token-free is what makes it
23
- * safe to copy between machines.
24
- */
25
-
26
- export type Mode = "read" | "write";
27
-
28
- /**
29
- * One entry in the default-deny allowlist. `custom` verbs are handled locally, not
30
- * proxied. `augment` tools ARE proxied (forwarded to the hosted MCP) but the router
31
- * post-processes the response afterward — gh-broker's own MCP contract for a proxied
32
- * tool is not required to match the hosted MCP's exact schema/response verbatim (see
33
- * router.ts's dispatchAugmented). `custom` and `augment` are mutually exclusive.
34
- */
35
- export interface ToolRule {
36
- name: string;
37
- mode: Mode;
38
- custom?: boolean;
39
- augment?: boolean;
40
- }
41
-
42
- /** Credential *references* (e.g. "cred:cldmv-read"), resolved via the SecretStore. */
43
- export interface OwnerCreds {
44
- read: string;
45
- write: string;
46
- /**
47
- * GitHub App installation id for this owner. Required only for verbs that need
48
- * an App token (currently `link_issue` → addClosingIssueReference, which is not
49
- * available to fine-grained PATs). Omit if the owner has no App installed.
50
- */
51
- installationId?: number;
52
- }
53
-
54
- /** GitHub App config — for App-token-only operations (`link_issue`). Optional. */
55
- export interface AppConfig {
56
- appId: number;
57
- /** Credential reference (e.g. "cred:gh-app-key") for the App's PEM private key. */
58
- privateKey: string;
59
- }
60
-
61
- /**
62
- * Where the actual token values live — chosen at install time. A provider seam:
63
- * - "systemd-creds" (default): encrypted, host-bound `.cred` files via
64
- * LoadCredentialEncrypted → $CREDENTIALS_DIRECTORY (encrypted at rest, not portable).
65
- * - "file": a JSON `{ "<name>": "<token>" }` map at an agent-unreadable path
66
- * (plaintext at rest, portable, platform-neutral).
67
- * Further providers (OS keychain, DPAPI, encrypted vault) can be added here
68
- * without touching call sites. Omit to use the systemd-creds provider.
69
- */
70
- export interface SecretsConfig {
71
- provider?: "systemd-creds" | "file";
72
- /** For provider "file": path to the JSON secrets file. Defaults to <configDir>/secrets.json. */
73
- path?: string;
74
- }
75
-
76
- export interface Config {
77
- /** e.g. "unix:/run/gh-broker/gh-broker.sock" */
78
- listen: string;
79
- /** GitHub hosted MCP base URL, e.g. "https://api.githubcopilot.com/mcp/" */
80
- upstream: string;
81
- tools: ToolRule[];
82
- owners: Record<string, OwnerCreds>;
83
- /**
84
- * Owner whose READ PAT handles calls that carry no owner in their args —
85
- * the genuinely global/identity reads (global security-advisory DB, `get_me`,
86
- * `list_notifications`). Must be a key of `owners`. Applies to `mode: "read"`
87
- * only; write calls always require an explicit owner. Omit to reject every
88
- * owner-less call (the stricter original behaviour).
89
- */
90
- defaultOwner?: string;
91
- /**
92
- * Optional GitHub App, reserved for `link_issue` (an App-token-only mutation).
93
- * `link_issue` is currently deferred — it is neither advertised nor dispatched —
94
- * so providing this does not enable any verb today.
95
- */
96
- app?: AppConfig;
97
- /** Optional secret-store backend selection. Defaults to the systemd-creds provider. */
98
- secrets?: SecretsConfig;
99
- }
100
-
101
- /** Resolves a "cred:<name>" reference to a token, never logging it. */
102
- export interface SecretStore {
103
- resolve(ref: string): string;
104
- }
package/src/upstream.ts DELETED
@@ -1,85 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/upstream.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
- import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
19
- import type { Config } from "./types.ts";
20
- import type { Upstream } from "./router.ts";
21
-
22
- const CLIENT_INFO = { name: "cldmv-gh-broker", version: "0.1.0" };
23
-
24
- /**
25
- * Pool of MCP client connections to the hosted GitHub MCP — one per bearer token,
26
- * so each org's PAT drives its own upstream session. Connections are created lazily
27
- * and reused; the PAT is only ever set as an `Authorization: Bearer` header and is
28
- * never logged.
29
- */
30
- export class UpstreamPool implements Upstream {
31
- private readonly url: URL;
32
- private readonly clients = new Map<string, Promise<Client>>();
33
-
34
- constructor(config: Config) {
35
- this.url = new URL(config.upstream);
36
- }
37
-
38
- private connect(token: string): Promise<Client> {
39
- const existing = this.clients.get(token);
40
- if (existing) return existing;
41
- const pending = (async (): Promise<Client> => {
42
- const client = new Client(CLIENT_INFO);
43
- const transport = new StreamableHTTPClientTransport(this.url, {
44
- requestInit: { headers: { Authorization: `Bearer ${token}` } }
45
- });
46
- await client.connect(transport);
47
- return client;
48
- })();
49
- // Don't cache a failed connect — let the next call retry.
50
- pending.catch(() => this.clients.delete(token));
51
- this.clients.set(token, pending);
52
- return pending;
53
- }
54
-
55
- async forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult> {
56
- return this.withReconnect(token, (client) => client.callTool({ name: tool, arguments: args }) as Promise<CallToolResult>);
57
- }
58
-
59
- /** Fetch upstream tool schemas (owner-independent) to build the advertised catalog. */
60
- async listToolsFor(token: string): Promise<Tool[]> {
61
- return this.withReconnect(token, async (client) => (await client.listTools()).tools);
62
- }
63
-
64
- /**
65
- * Run an upstream op with a single transparent reconnect. The hosted MCP's
66
- * streamable-HTTP session can die server-side (idle-session expiry, upstream
67
- * restart) even though our cached client still looks connected — the next call
68
- * then fails with "invalid session". Rather than surface that first failure to
69
- * the agent (and leave it stuck until the whole broker restarts), evict the dead
70
- * client and retry ONCE with a fresh connection. A genuinely broken call (bad
71
- * token, real upstream error) fails the retry too and propagates.
72
- */
73
- private async withReconnect<T>(token: string, op: (client: Client) => Promise<T>): Promise<T> {
74
- for (let attempt = 0; ; attempt++) {
75
- const client = await this.connect(token);
76
- try {
77
- return await op(client);
78
- } catch (err) {
79
- // Evict the (possibly stale) session so this retry / the next call reconnects.
80
- this.clients.delete(token);
81
- if (attempt >= 1) throw err;
82
- }
83
- }
84
- }
85
- }