@sjawhar/opencode-legion-envoy 0.5.2 → 0.6.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/AGENTS.md +1 -1
- package/bin/dispatch-mcp-shim.ts +5 -73
- package/package.json +1 -1
- package/src/__tests__/index.test.ts +7 -12
- package/src/__tests__/ss.test.ts +34 -0
- package/src/config/index.ts +2 -1
- package/src/dispatch-subscribe.ts +1 -1
- package/src/port.ts +3 -10
- package/src/server.ts +12 -9
- package/src/ss.ts +19 -0
- package/src/tui-port.ts +5 -13
- package/tsconfig.json +1 -1
- package/src/__tests__/dispatch-mcp-bridge.test.ts +0 -339
- package/src/dispatch-mcp-bridge.ts +0 -310
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,7 @@ It is the user-facing bridge between OpenCode sessions and Envoy transport.
|
|
|
16
16
|
| Packaging metadata | `package.json` | npm identity, `exports` map, scripts |
|
|
17
17
|
| TUI: `/whoami` + sidebar | `src/tui.tsx` | slash command + session-id/port sidebar; loaded via the `./tui` export. Ships as `.tsx` source (no build/`dist`) — Bun transpiles it natively at load, so `@opentui/core` + `@opentui/solid` MUST be `peerDependencies` (not `devDependencies`) so the `@jsxImportSource @opentui/solid` runtime resolves in the consumer's install tree |
|
|
18
18
|
| Host rollout helper | `scripts/sync-host.sh` | sync packed release tarball + shim to remote host |
|
|
19
|
-
| Dispatch MCP + auto-subscribe | `src/dispatch-mcp.ts`, `src/dispatch-subscribe.ts` | injects the
|
|
19
|
+
| Dispatch MCP + auto-subscribe | `src/dispatch-mcp.ts`, `src/dispatch-subscribe.ts` | injects the shim wrapper in `bin/`; shared forwarding/token code lives in `@legion/envoy-client`. `tool.execute.after` auto-subscribes dispatch callers to new thread topics. |
|
|
20
20
|
|
|
21
21
|
## Critical conventions
|
|
22
22
|
|
package/bin/dispatch-mcp-shim.ts
CHANGED
|
@@ -1,75 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// envoy-plugin local MCP shim.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// dispatch server's Streamable HTTP /mcp endpoint with a fresh GitHub
|
|
7
|
-
// bearer minted via the user's `gh` shim, and writes responses to stdout.
|
|
8
|
-
//
|
|
9
|
-
// Token rotation is invisible to OpenCode — the shim handles 50-minute
|
|
10
|
-
// refresh cycles + immediate retry on 401. This avoids the "MCP dies
|
|
11
|
-
// after 1 hour" failure mode of static-header configurations.
|
|
2
|
+
// envoy-plugin local MCP shim entry point. The forwarding logic is shared
|
|
3
|
+
// with the other Envoy adapters in @legion/envoy-client; OpenCode spawns
|
|
4
|
+
// this wrapper via the `mcp.envoy` entry injected by src/dispatch-mcp.ts.
|
|
5
|
+
import { runDispatchMcpShim } from "@legion/envoy-client/dispatch-mcp-shim";
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
createBridge,
|
|
16
|
-
defaultGhTokenGetter,
|
|
17
|
-
type JsonRpcRequest,
|
|
18
|
-
} from "../src/dispatch-mcp-bridge";
|
|
19
|
-
|
|
20
|
-
const remoteUrl = process.env.DISPATCH_MCP_URL;
|
|
21
|
-
if (!remoteUrl) {
|
|
22
|
-
process.stderr.write("envoy-dispatch shim: DISPATCH_MCP_URL is required\n");
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const bridge = createBridge({
|
|
27
|
-
remoteUrl,
|
|
28
|
-
getToken: defaultGhTokenGetter,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
32
|
-
|
|
33
|
-
let inflight = 0;
|
|
34
|
-
let closed = false;
|
|
35
|
-
|
|
36
|
-
// Serialize incoming requests. MCP requires the initialize handshake to
|
|
37
|
-
// complete before any tool calls are processed; running rl.on("line")
|
|
38
|
-
// callbacks in parallel would race tools/call against initialize and hit
|
|
39
|
-
// `invalid during session initialization` from the server. Even after
|
|
40
|
-
// init, sequencing keeps the wire ordering deterministic, which is what
|
|
41
|
-
// OpenCode expects for stdio MCP transports.
|
|
42
|
-
let chain: Promise<void> = Promise.resolve();
|
|
43
|
-
|
|
44
|
-
function maybeExit(): void {
|
|
45
|
-
if (closed && inflight === 0) process.exit(0);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
rl.on("line", (line) => {
|
|
49
|
-
const trimmed = line.trim();
|
|
50
|
-
if (!trimmed) return;
|
|
51
|
-
inflight++;
|
|
52
|
-
chain = chain.then(async () => {
|
|
53
|
-
try {
|
|
54
|
-
const request = JSON.parse(trimmed) as JsonRpcRequest;
|
|
55
|
-
const response = await bridge.handle(request);
|
|
56
|
-
if (response !== null) {
|
|
57
|
-
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
58
|
-
}
|
|
59
|
-
} catch (err) {
|
|
60
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
-
process.stderr.write(`envoy-dispatch shim: ${msg}\n`);
|
|
62
|
-
} finally {
|
|
63
|
-
inflight--;
|
|
64
|
-
maybeExit();
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
rl.on("close", () => {
|
|
70
|
-
closed = true;
|
|
71
|
-
maybeExit();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
process.on("SIGTERM", () => process.exit(0));
|
|
75
|
-
process.on("SIGINT", () => process.exit(0));
|
|
7
|
+
runDispatchMcpShim();
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
|
|
3
4
|
// Suppress console.error during tests
|
|
4
5
|
const originalError = console.error;
|
|
@@ -72,9 +73,7 @@ describe("envoy plugin init", () => {
|
|
|
72
73
|
describe("envoy_whoami", () => {
|
|
73
74
|
it("returns session identity when Envoy is unavailable", async () => {
|
|
74
75
|
const originalEnvoyUrl = process.env.ENVOY_URL;
|
|
75
|
-
const originalHostname = process.env.HOSTNAME;
|
|
76
76
|
process.env.ENVOY_URL = "http://127.0.0.1:59999";
|
|
77
|
-
process.env.HOSTNAME = "test-machine";
|
|
78
77
|
|
|
79
78
|
try {
|
|
80
79
|
const pluginModule = await import("../server");
|
|
@@ -91,25 +90,21 @@ describe("envoy_whoami", () => {
|
|
|
91
90
|
|
|
92
91
|
const parsed = JSON.parse(typeof result === "string" ? result : result.output);
|
|
93
92
|
expect(parsed.session_id).toBe("ses_test_whoami");
|
|
94
|
-
expect(parsed.machine_id).toBe(
|
|
93
|
+
expect(parsed.machine_id).toBe(os.hostname());
|
|
95
94
|
expect(parsed.dir).toBe("/tmp/test-workspace");
|
|
96
95
|
expect(parsed).not.toHaveProperty("topics");
|
|
97
96
|
expect(parsed.port === null || typeof parsed.port === "number").toBe(true);
|
|
98
97
|
} finally {
|
|
99
98
|
process.env.ENVOY_URL = originalEnvoyUrl;
|
|
100
|
-
if (originalHostname === undefined) {
|
|
101
|
-
delete process.env.HOSTNAME;
|
|
102
|
-
} else {
|
|
103
|
-
process.env.HOSTNAME = originalHostname;
|
|
104
|
-
}
|
|
105
99
|
}
|
|
106
100
|
});
|
|
107
101
|
|
|
108
|
-
it("
|
|
102
|
+
it("reports machine_id from the real hostname, not the HOSTNAME env var", async () => {
|
|
109
103
|
const originalEnvoyUrl = process.env.ENVOY_URL;
|
|
110
104
|
const originalHostname = process.env.HOSTNAME;
|
|
111
105
|
process.env.ENVOY_URL = "http://127.0.0.1:59999";
|
|
112
|
-
|
|
106
|
+
// Same host must never report two machine IDs depending on shell env.
|
|
107
|
+
process.env.HOSTNAME = "some-other-name";
|
|
113
108
|
|
|
114
109
|
try {
|
|
115
110
|
const pluginModule = await import("../server");
|
|
@@ -119,13 +114,13 @@ describe("envoy_whoami", () => {
|
|
|
119
114
|
} as never);
|
|
120
115
|
|
|
121
116
|
const result = await hooks.tool.envoy_whoami.execute({}, {
|
|
122
|
-
sessionID: "
|
|
117
|
+
sessionID: "ses_env_hostname",
|
|
123
118
|
directory: "/tmp",
|
|
124
119
|
metadata: mock(() => {}),
|
|
125
120
|
} as never);
|
|
126
121
|
|
|
127
122
|
const parsed = JSON.parse(typeof result === "string" ? result : result.output);
|
|
128
|
-
expect(parsed.machine_id).toBe(
|
|
123
|
+
expect(parsed.machine_id).toBe(os.hostname());
|
|
129
124
|
} finally {
|
|
130
125
|
process.env.ENVOY_URL = originalEnvoyUrl;
|
|
131
126
|
if (originalHostname === undefined) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { portFromSsOutput } from "../ss";
|
|
3
|
+
|
|
4
|
+
const header = "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process";
|
|
5
|
+
|
|
6
|
+
function listen(local: string, pid: number): string {
|
|
7
|
+
return `LISTEN 0 511 ${local} 0.0.0.0:* users:(("bun",pid=${pid},fd=6))`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("portFromSsOutput", () => {
|
|
11
|
+
test("returns null when the process id has no listening socket", () => {
|
|
12
|
+
const output = [header, listen("127.0.0.1:4096", 99_999)].join("\n");
|
|
13
|
+
|
|
14
|
+
expect(portFromSsOutput(output, 12_345)).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("skips matching process rows whose local address column is malformed", () => {
|
|
18
|
+
const output = [header, listen("not-a-local-address", 123)].join("\n");
|
|
19
|
+
|
|
20
|
+
expect(portFromSsOutput(output, 123)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("ignores zero and negative local ports", () => {
|
|
24
|
+
const output = [header, listen("127.0.0.1:0", 123), listen("127.0.0.1:-1", 123)].join("\n");
|
|
25
|
+
|
|
26
|
+
expect(portFromSsOutput(output, 123)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("matches the full pid token before returning a port", () => {
|
|
30
|
+
const output = [header, listen("127.0.0.1:9999", 123), listen("127.0.0.1:4444", 12)].join("\n");
|
|
31
|
+
|
|
32
|
+
expect(portFromSsOutput(output, 12)).toBe(4444);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { messageFor } from "@legion/envoy-client/errors";
|
|
4
5
|
import { logger } from "../log";
|
|
5
6
|
import { type EnvoyConfig, EnvoyConfigSchema } from "./schema";
|
|
6
7
|
|
|
@@ -23,7 +24,7 @@ function readConfigFile(filePath: string): EnvoyConfig | null {
|
|
|
23
24
|
}
|
|
24
25
|
return parsed.data as EnvoyConfig;
|
|
25
26
|
} catch (error) {
|
|
26
|
-
const message =
|
|
27
|
+
const message = messageFor(error);
|
|
27
28
|
logger.warn(`[envoy-plugin] Failed to load config at ${filePath}: ${message}`);
|
|
28
29
|
return null;
|
|
29
30
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Auto-subscription wiring for the envoy_dispatch MCP tool
|
|
1
|
+
// Auto-subscription wiring for the envoy_dispatch MCP tool.
|
|
2
2
|
//
|
|
3
3
|
// When an agent opens a Dispatch thread via the envoy_dispatch MCP tool, the
|
|
4
4
|
// human answers by commenting on the resulting GitHub sub-issue. For the agent
|
package/src/port.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { portFromSsOutput } from "./ss";
|
|
2
3
|
|
|
3
4
|
type ExecFn = (
|
|
4
5
|
command: string,
|
|
@@ -33,17 +34,9 @@ export async function resolvePort(
|
|
|
33
34
|
// Fallback: find listening port via ss(8) by PID
|
|
34
35
|
try {
|
|
35
36
|
const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const parts = line.trim().split(/\s+/);
|
|
39
|
-
const local = parts[3];
|
|
40
|
-
const match = local?.match(/:(\d+)$/);
|
|
41
|
-
if (!match) continue;
|
|
42
|
-
const port = Number.parseInt(match[1], 10);
|
|
43
|
-
if (Number.isFinite(port) && port > 0) return port;
|
|
44
|
-
}
|
|
37
|
+
const port = portFromSsOutput(output, process.pid);
|
|
38
|
+
if (port !== null) return port;
|
|
45
39
|
} catch {}
|
|
46
40
|
|
|
47
41
|
return null;
|
|
48
42
|
}
|
|
49
|
-
// trigger publish
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agentSubject } from "@legion/contracts";
|
|
2
2
|
import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
|
|
3
|
+
import { machineID } from "@legion/envoy-client/machine";
|
|
3
4
|
import { envoyToolSpecs } from "@legion/envoy-client/tool-contract";
|
|
4
5
|
import { createEnvoyClient } from "@legion/envoy-client/transport";
|
|
5
6
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
@@ -41,7 +42,10 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
41
42
|
if (!port && !portWarningLogged) {
|
|
42
43
|
portWarningLogged = true;
|
|
43
44
|
logger.error(
|
|
44
|
-
|
|
45
|
+
[
|
|
46
|
+
`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href},`,
|
|
47
|
+
`pid=${process.pid}`,
|
|
48
|
+
].join(" ")
|
|
45
49
|
);
|
|
46
50
|
}
|
|
47
51
|
if (port) {
|
|
@@ -218,10 +222,10 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
218
222
|
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
219
223
|
output: { title: string; output: string; metadata: unknown }
|
|
220
224
|
) => {
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
+
// When this session opens a Dispatch thread via the envoy_dispatch MCP
|
|
226
|
+
// tool, auto-subscribe it to the thread's GitHub topic so the human's
|
|
227
|
+
// reply is delivered back through Envoy. Best-effort — a subscribe
|
|
228
|
+
// failure must never surface to the model or fail the tool call.
|
|
225
229
|
const topic = dispatchSubscriptionTopic(input.tool, output.output);
|
|
226
230
|
if (!topic) return;
|
|
227
231
|
try {
|
|
@@ -234,9 +238,8 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
234
238
|
driving: true,
|
|
235
239
|
});
|
|
236
240
|
} catch (err) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
);
|
|
241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
242
|
+
logger.warn(`[envoy-plugin] dispatch auto-subscribe failed: ${message}`);
|
|
240
243
|
}
|
|
241
244
|
},
|
|
242
245
|
// Cleanup hook (used by tests; production relies on process 'exit').
|
|
@@ -325,7 +328,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
325
328
|
return JSON.stringify(
|
|
326
329
|
{
|
|
327
330
|
session_id: sessionID,
|
|
328
|
-
machine_id:
|
|
331
|
+
machine_id: machineID(),
|
|
329
332
|
port,
|
|
330
333
|
dir: ctx.directory,
|
|
331
334
|
},
|
package/src/ss.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the listening TCP port for `pid` from `ss -tlnp` output.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the server-side and TUI-side port resolvers, which differ only in
|
|
5
|
+
* how they discover the pid and whether they exec ss(8) sync or async.
|
|
6
|
+
*/
|
|
7
|
+
export function portFromSsOutput(output: string, pid: number): number | null {
|
|
8
|
+
const pidPattern = new RegExp(`\\bpid=${pid}\\b`);
|
|
9
|
+
for (const line of output.split("\n")) {
|
|
10
|
+
if (!pidPattern.test(line)) continue;
|
|
11
|
+
const parts = line.trim().split(/\s+/);
|
|
12
|
+
const local = parts[3];
|
|
13
|
+
const match = local?.match(/:(\d+)$/);
|
|
14
|
+
if (!match) continue;
|
|
15
|
+
const port = Number.parseInt(match[1], 10);
|
|
16
|
+
if (Number.isFinite(port) && port > 0) return port;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
package/src/tui-port.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { portFromSsOutput } from "./ss";
|
|
2
3
|
|
|
3
4
|
type ExecSyncFn = (command: string, args: string[], options: { encoding: string }) => string;
|
|
4
5
|
|
|
@@ -36,19 +37,10 @@ export function resolveCurrentProcessPort(exec: ExecSyncFn = defaultExecSync): n
|
|
|
36
37
|
|
|
37
38
|
function resolveProcessPort(pid: number, exec: ExecSyncFn = defaultExecSync): number | null {
|
|
38
39
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const local = parts[3];
|
|
44
|
-
const match = local?.match(/:(\d+)$/);
|
|
45
|
-
if (!match) continue;
|
|
46
|
-
const port = Number.parseInt(match[1], 10);
|
|
47
|
-
if (Number.isFinite(port) && port > 0) return port;
|
|
48
|
-
}
|
|
49
|
-
} catch {}
|
|
50
|
-
|
|
51
|
-
return null;
|
|
40
|
+
return portFromSsOutput(exec("ss", ["-tlnp"], { encoding: "utf-8" }), pid);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
export function resolveSessionProcessPort(
|
package/tsconfig.json
CHANGED
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { createBridge, type JsonRpcRequest } from "../dispatch-mcp-bridge";
|
|
3
|
-
|
|
4
|
-
interface MockResponse {
|
|
5
|
-
status: number;
|
|
6
|
-
statusText?: string;
|
|
7
|
-
headers?: Record<string, string>;
|
|
8
|
-
contentType?: string;
|
|
9
|
-
body: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function fakeFetch(responses: MockResponse[]) {
|
|
13
|
-
let idx = 0;
|
|
14
|
-
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
15
|
-
const impl = (url: string, init?: RequestInit) => {
|
|
16
|
-
calls.push({ url, init: init ?? {} });
|
|
17
|
-
const next = responses[idx++];
|
|
18
|
-
if (!next) throw new Error(`no mock response for call #${idx}`);
|
|
19
|
-
const headers = new Headers({
|
|
20
|
-
"content-type": next.contentType ?? "application/json",
|
|
21
|
-
...(next.headers ?? {}),
|
|
22
|
-
});
|
|
23
|
-
return Promise.resolve(
|
|
24
|
-
new Response(next.body, {
|
|
25
|
-
status: next.status,
|
|
26
|
-
statusText: next.statusText ?? "",
|
|
27
|
-
headers,
|
|
28
|
-
})
|
|
29
|
-
);
|
|
30
|
-
};
|
|
31
|
-
return { impl: impl as unknown as typeof fetch, calls };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function sseEnvelope(payload: object): string {
|
|
35
|
-
return `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe("dispatch-mcp-bridge", () => {
|
|
39
|
-
it("forwards a request with a fresh bearer and returns the parsed SSE response", async () => {
|
|
40
|
-
const f = fakeFetch([
|
|
41
|
-
{
|
|
42
|
-
status: 200,
|
|
43
|
-
contentType: "text/event-stream",
|
|
44
|
-
headers: { "mcp-session-id": "S1" },
|
|
45
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: { ok: true } }),
|
|
46
|
-
},
|
|
47
|
-
]);
|
|
48
|
-
const bridge = createBridge({
|
|
49
|
-
remoteUrl: "http://example/mcp",
|
|
50
|
-
getToken: async () => "tok-A",
|
|
51
|
-
fetchImpl: f.impl,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const req: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "tools/list" };
|
|
55
|
-
const res = await bridge.handle(req);
|
|
56
|
-
|
|
57
|
-
expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } });
|
|
58
|
-
expect(f.calls).toHaveLength(1);
|
|
59
|
-
expect((f.calls[0]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-A");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("reuses the cached token on a follow-up request and reuses the session id", async () => {
|
|
63
|
-
let tokenCalls = 0;
|
|
64
|
-
const f = fakeFetch([
|
|
65
|
-
{
|
|
66
|
-
status: 200,
|
|
67
|
-
contentType: "text/event-stream",
|
|
68
|
-
headers: { "mcp-session-id": "S2" },
|
|
69
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: { phase: "init" } }),
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
status: 200,
|
|
73
|
-
contentType: "text/event-stream",
|
|
74
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 2, result: { phase: "list" } }),
|
|
75
|
-
},
|
|
76
|
-
]);
|
|
77
|
-
const bridge = createBridge({
|
|
78
|
-
remoteUrl: "http://example/mcp",
|
|
79
|
-
getToken: async () => {
|
|
80
|
-
tokenCalls++;
|
|
81
|
-
return "tok-cached";
|
|
82
|
-
},
|
|
83
|
-
fetchImpl: f.impl,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
await bridge.handle({ jsonrpc: "2.0", id: 1, method: "initialize" });
|
|
87
|
-
await bridge.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
88
|
-
|
|
89
|
-
expect(tokenCalls).toBe(1);
|
|
90
|
-
expect((f.calls[1]?.init.headers as Record<string, string>)["Mcp-Session-Id"]).toBe("S2");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("refreshes the token after the cache TTL elapses", async () => {
|
|
94
|
-
const responses: MockResponse[] = [
|
|
95
|
-
{
|
|
96
|
-
status: 200,
|
|
97
|
-
contentType: "text/event-stream",
|
|
98
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: 1 }),
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
status: 200,
|
|
102
|
-
contentType: "text/event-stream",
|
|
103
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 2, result: 2 }),
|
|
104
|
-
},
|
|
105
|
-
];
|
|
106
|
-
const f = fakeFetch(responses);
|
|
107
|
-
let issued = 0;
|
|
108
|
-
let clock = 1000;
|
|
109
|
-
|
|
110
|
-
const bridge = createBridge({
|
|
111
|
-
remoteUrl: "http://example/mcp",
|
|
112
|
-
getToken: async () => `tok-${++issued}`,
|
|
113
|
-
fetchImpl: f.impl,
|
|
114
|
-
tokenCacheTtlMs: 60_000,
|
|
115
|
-
now: () => clock,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
|
|
119
|
-
clock += 120_000; // beyond TTL
|
|
120
|
-
await bridge.handle({ jsonrpc: "2.0", id: 2, method: "x" });
|
|
121
|
-
|
|
122
|
-
expect(issued).toBe(2);
|
|
123
|
-
expect((f.calls[0]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-1");
|
|
124
|
-
expect((f.calls[1]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-2");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("retries once on 401 with a forced token refresh", async () => {
|
|
128
|
-
const f = fakeFetch([
|
|
129
|
-
{ status: 401, body: "unauthorized" },
|
|
130
|
-
{
|
|
131
|
-
status: 200,
|
|
132
|
-
contentType: "text/event-stream",
|
|
133
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
|
|
134
|
-
},
|
|
135
|
-
]);
|
|
136
|
-
let issued = 0;
|
|
137
|
-
const bridge = createBridge({
|
|
138
|
-
remoteUrl: "http://example/mcp",
|
|
139
|
-
getToken: async () => `tok-${++issued}`,
|
|
140
|
-
fetchImpl: f.impl,
|
|
141
|
-
logError: () => {},
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
145
|
-
expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
|
|
146
|
-
expect(issued).toBe(2); // first attempt cached tok-1, retry forced tok-2
|
|
147
|
-
expect(f.calls).toHaveLength(2);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("retries when remote returns HTTP 200 but tool result reports upstream 401", async () => {
|
|
151
|
-
const f = fakeFetch([
|
|
152
|
-
{
|
|
153
|
-
status: 200,
|
|
154
|
-
contentType: "application/json",
|
|
155
|
-
body: JSON.stringify({
|
|
156
|
-
jsonrpc: "2.0",
|
|
157
|
-
id: 1,
|
|
158
|
-
result: {
|
|
159
|
-
isError: true,
|
|
160
|
-
content: [
|
|
161
|
-
{
|
|
162
|
-
type: "text",
|
|
163
|
-
text: "search issues: GET https://api.github.com/search/issues?q=x: 401 Bad credentials []",
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
},
|
|
167
|
-
}),
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
status: 200,
|
|
171
|
-
contentType: "text/event-stream",
|
|
172
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
|
|
173
|
-
},
|
|
174
|
-
]);
|
|
175
|
-
let issued = 0;
|
|
176
|
-
const bridge = createBridge({
|
|
177
|
-
remoteUrl: "http://example/mcp",
|
|
178
|
-
getToken: async () => `tok-${++issued}`,
|
|
179
|
-
fetchImpl: f.impl,
|
|
180
|
-
logError: () => {},
|
|
181
|
-
});
|
|
182
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/call" });
|
|
183
|
-
expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
|
|
184
|
-
expect(issued).toBe(2);
|
|
185
|
-
expect(f.calls).toHaveLength(2);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("retries when remote returns a JSON-RPC error whose message reports upstream 401", async () => {
|
|
189
|
-
const f = fakeFetch([
|
|
190
|
-
{
|
|
191
|
-
status: 200,
|
|
192
|
-
contentType: "application/json",
|
|
193
|
-
body: JSON.stringify({
|
|
194
|
-
jsonrpc: "2.0",
|
|
195
|
-
id: 1,
|
|
196
|
-
error: { code: -32603, message: "search issues: 401 Bad credentials" },
|
|
197
|
-
}),
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
status: 200,
|
|
201
|
-
contentType: "text/event-stream",
|
|
202
|
-
body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
|
|
203
|
-
},
|
|
204
|
-
]);
|
|
205
|
-
let issued = 0;
|
|
206
|
-
const bridge = createBridge({
|
|
207
|
-
remoteUrl: "http://example/mcp",
|
|
208
|
-
getToken: async () => `tok-${++issued}`,
|
|
209
|
-
fetchImpl: f.impl,
|
|
210
|
-
logError: () => {},
|
|
211
|
-
});
|
|
212
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/call" });
|
|
213
|
-
expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
|
|
214
|
-
expect(issued).toBe(2);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("returns a JSON-RPC error when the token getter yields null", async () => {
|
|
218
|
-
const f = fakeFetch([]);
|
|
219
|
-
const bridge = createBridge({
|
|
220
|
-
remoteUrl: "http://example/mcp",
|
|
221
|
-
getToken: async () => null,
|
|
222
|
-
fetchImpl: f.impl,
|
|
223
|
-
});
|
|
224
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 7, method: "tools/call" });
|
|
225
|
-
expect(res?.error?.code).toBe(-32000);
|
|
226
|
-
expect(res?.error?.message).toContain("gh auth token");
|
|
227
|
-
expect(f.calls).toHaveLength(0);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("returns null for notifications (no id) and still forwards them", async () => {
|
|
231
|
-
const f = fakeFetch([{ status: 200, contentType: "application/json", body: "{}" }]);
|
|
232
|
-
const bridge = createBridge({
|
|
233
|
-
remoteUrl: "http://example/mcp",
|
|
234
|
-
getToken: async () => "tok",
|
|
235
|
-
fetchImpl: f.impl,
|
|
236
|
-
});
|
|
237
|
-
const res = await bridge.handle({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
238
|
-
expect(res).toBeNull();
|
|
239
|
-
expect(f.calls).toHaveLength(1);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("returns a JSON-RPC error on remote non-200 status", async () => {
|
|
243
|
-
const f = fakeFetch([{ status: 503, statusText: "Service Unavailable", body: "down" }]);
|
|
244
|
-
const bridge = createBridge({
|
|
245
|
-
remoteUrl: "http://example/mcp",
|
|
246
|
-
getToken: async () => "tok",
|
|
247
|
-
fetchImpl: f.impl,
|
|
248
|
-
});
|
|
249
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
|
|
250
|
-
expect(res?.error?.code).toBe(-32603);
|
|
251
|
-
expect(res?.error?.message).toContain("503");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("returns a JSON-RPC error when fetch throws", async () => {
|
|
255
|
-
const erroringFetch = mock(async () => {
|
|
256
|
-
throw new Error("ECONNREFUSED");
|
|
257
|
-
});
|
|
258
|
-
const bridge = createBridge({
|
|
259
|
-
remoteUrl: "http://example/mcp",
|
|
260
|
-
getToken: async () => "tok",
|
|
261
|
-
fetchImpl: erroringFetch as unknown as typeof fetch,
|
|
262
|
-
});
|
|
263
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
|
|
264
|
-
expect(res?.error?.code).toBe(-32603);
|
|
265
|
-
expect(res?.error?.message).toContain("ECONNREFUSED");
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("normalizes union-type-null arrays in tools/list inputSchema so Gemini accepts them", async () => {
|
|
269
|
-
// Mirrors the real EnvoyDispatch schema the remote server emits: nullable arrays
|
|
270
|
-
// expressed as JSON-Schema union types { type: ["null", "array"] }, which Gemini rejects
|
|
271
|
-
// (array branch lacks items / orphaned items). The bridge must collapse these in transit.
|
|
272
|
-
const toolsList = {
|
|
273
|
-
jsonrpc: "2.0",
|
|
274
|
-
id: 1,
|
|
275
|
-
result: {
|
|
276
|
-
tools: [
|
|
277
|
-
{
|
|
278
|
-
name: "envoy_dispatch",
|
|
279
|
-
description: "Create a Dispatch thread",
|
|
280
|
-
inputSchema: {
|
|
281
|
-
type: "object",
|
|
282
|
-
required: ["parent", "subject", "body"],
|
|
283
|
-
properties: {
|
|
284
|
-
parent: { type: "string" },
|
|
285
|
-
ask: {
|
|
286
|
-
type: ["null", "array"],
|
|
287
|
-
items: {
|
|
288
|
-
type: "object",
|
|
289
|
-
required: ["question", "options"],
|
|
290
|
-
properties: {
|
|
291
|
-
question: { type: "string" },
|
|
292
|
-
custom: { type: ["null", "boolean"] },
|
|
293
|
-
options: {
|
|
294
|
-
type: ["null", "array"],
|
|
295
|
-
items: {
|
|
296
|
-
type: "object",
|
|
297
|
-
required: ["label"],
|
|
298
|
-
properties: { label: { type: "string" } },
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
},
|
|
307
|
-
],
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
const f = fakeFetch([
|
|
311
|
-
{ status: 200, contentType: "application/json", body: JSON.stringify(toolsList) },
|
|
312
|
-
]);
|
|
313
|
-
const bridge = createBridge({
|
|
314
|
-
remoteUrl: "http://example/mcp",
|
|
315
|
-
getToken: async () => "tok",
|
|
316
|
-
fetchImpl: f.impl,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
320
|
-
type SchemaNode = {
|
|
321
|
-
type?: unknown;
|
|
322
|
-
items?: SchemaNode;
|
|
323
|
-
properties?: Record<string, SchemaNode>;
|
|
324
|
-
};
|
|
325
|
-
const result = res?.result as { tools: Array<{ inputSchema: SchemaNode }> };
|
|
326
|
-
const ask = result.tools[0]?.inputSchema.properties?.ask;
|
|
327
|
-
|
|
328
|
-
// Nullable array collapses to a single-type array with items preserved.
|
|
329
|
-
expect(ask?.type).toBe("array");
|
|
330
|
-
expect(ask?.items).toBeDefined();
|
|
331
|
-
// Nested nullable array (options) collapses too.
|
|
332
|
-
expect(ask?.items?.properties?.options.type).toBe("array");
|
|
333
|
-
expect(ask?.items?.properties?.options.items).toBeDefined();
|
|
334
|
-
// Nullable boolean collapses to a single-type boolean.
|
|
335
|
-
expect(ask?.items?.properties?.custom.type).toBe("boolean");
|
|
336
|
-
// Nothing in the schema still uses a union type array (the shape Gemini rejects).
|
|
337
|
-
expect(JSON.stringify(res)).not.toContain('["null"');
|
|
338
|
-
});
|
|
339
|
-
});
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
// Core forwarding logic for the local MCP shim that proxies opencode's
|
|
2
|
-
// stdio MCP traffic to the remote dispatch server's Streamable HTTP /mcp,
|
|
3
|
-
// minting a fresh GitHub bearer per request via the user's `gh` shim.
|
|
4
|
-
//
|
|
5
|
-
// Exposed as a library so the bridge can be unit-tested without spawning
|
|
6
|
-
// a real subprocess. The CLI wrapper in bin/dispatch-mcp-shim.ts wires
|
|
7
|
-
// this to stdin/stdout.
|
|
8
|
-
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
10
|
-
import { promisify } from "node:util";
|
|
11
|
-
|
|
12
|
-
const execFileAsync = promisify(execFile);
|
|
13
|
-
|
|
14
|
-
/** Refresh well before the 1h gh-app installation token expiry. */
|
|
15
|
-
const DEFAULT_TOKEN_CACHE_TTL_MS = 50 * 60 * 1000;
|
|
16
|
-
|
|
17
|
-
export interface JsonRpcRequest {
|
|
18
|
-
jsonrpc: "2.0";
|
|
19
|
-
id?: string | number;
|
|
20
|
-
method: string;
|
|
21
|
-
params?: unknown;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface JsonRpcResponse {
|
|
25
|
-
jsonrpc: "2.0";
|
|
26
|
-
id: string | number | null;
|
|
27
|
-
result?: unknown;
|
|
28
|
-
error?: { code: number; message: string; data?: unknown };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type TokenGetter = () => Promise<string | null>;
|
|
32
|
-
export type FetchImpl = typeof fetch;
|
|
33
|
-
|
|
34
|
-
export interface BridgeOptions {
|
|
35
|
-
remoteUrl: string;
|
|
36
|
-
getToken: TokenGetter;
|
|
37
|
-
fetchImpl?: FetchImpl;
|
|
38
|
-
tokenCacheTtlMs?: number;
|
|
39
|
-
/** Optional logger for stderr-side diagnostics. */
|
|
40
|
-
logError?: (msg: string) => void;
|
|
41
|
-
/** Optional clock injection for tests. */
|
|
42
|
-
now?: () => number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface Bridge {
|
|
46
|
-
handle(request: JsonRpcRequest): Promise<JsonRpcResponse | null>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export const defaultGhTokenGetter: TokenGetter = async () => {
|
|
50
|
-
try {
|
|
51
|
-
const { stdout } = await execFileAsync("gh", ["auth", "token"], {
|
|
52
|
-
timeout: 5_000,
|
|
53
|
-
});
|
|
54
|
-
const value = stdout.trim();
|
|
55
|
-
return value.length > 0 ? value : null;
|
|
56
|
-
} catch {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Parse a Streamable-HTTP SSE response and return the first `event: message`
|
|
63
|
-
* payload as parsed JSON. Returns null if no message line was found.
|
|
64
|
-
*/
|
|
65
|
-
function parseSseBody(body: string): unknown {
|
|
66
|
-
for (const line of body.split("\n")) {
|
|
67
|
-
const match = line.match(/^data:\s*(.+)$/);
|
|
68
|
-
if (match) {
|
|
69
|
-
return JSON.parse(match[1] as string);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Detect whether a parsed JSON-RPC response indicates that the *upstream*
|
|
77
|
-
* GitHub call failed with 401 (e.g. expired App-installation token).
|
|
78
|
-
*
|
|
79
|
-
* The Go MCP server forwards GitHub errors verbatim in the tool result
|
|
80
|
-
* (`result.isError: true`, content text contains "401 Bad credentials")
|
|
81
|
-
* or as a JSON-RPC error message. Either signal triggers a one-shot retry
|
|
82
|
-
* with a freshly-minted token.
|
|
83
|
-
*/
|
|
84
|
-
function hasUpstreamUnauthorized(parsed: unknown): boolean {
|
|
85
|
-
if (!parsed || typeof parsed !== "object") return false;
|
|
86
|
-
const obj = parsed as { error?: { message?: unknown }; result?: unknown };
|
|
87
|
-
if (
|
|
88
|
-
obj.error &&
|
|
89
|
-
typeof obj.error.message === "string" &&
|
|
90
|
-
containsUnauthorized(obj.error.message)
|
|
91
|
-
) {
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
const result = obj.result as { isError?: unknown; content?: unknown } | undefined;
|
|
95
|
-
if (!result || result.isError !== true || !Array.isArray(result.content)) return false;
|
|
96
|
-
for (const item of result.content) {
|
|
97
|
-
if (
|
|
98
|
-
item &&
|
|
99
|
-
typeof item === "object" &&
|
|
100
|
-
"text" in item &&
|
|
101
|
-
typeof (item as { text: unknown }).text === "string"
|
|
102
|
-
) {
|
|
103
|
-
if (containsUnauthorized((item as { text: string }).text)) return true;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function containsUnauthorized(msg: string): boolean {
|
|
110
|
-
return /\b401\b/.test(msg) && /bad credentials|unauthorized/i.test(msg);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Collapse JSON-Schema union `type` arrays (e.g. { type: ["null", "array"] }) into a
|
|
115
|
-
* single-type schema. Google Gemini's function-declaration validator rejects union-type
|
|
116
|
-
* arrays: the array branch loses its `items` and the top-level `items` is left orphaned,
|
|
117
|
-
* producing `any_of[0].items: missing field`. Remote dispatch tools express nullable fields
|
|
118
|
-
* this way, so we normalize them in transit. Dropping "null" is safe — the model omits or
|
|
119
|
-
* passes a real value, and `required` already governs presence.
|
|
120
|
-
*/
|
|
121
|
-
function normalizeSchemaUnionTypes(node: unknown): unknown {
|
|
122
|
-
if (Array.isArray(node)) return node.map(normalizeSchemaUnionTypes);
|
|
123
|
-
if (!node || typeof node !== "object") return node;
|
|
124
|
-
const result: Record<string, unknown> = {};
|
|
125
|
-
for (const [key, value] of Object.entries(node)) {
|
|
126
|
-
result[key] = normalizeSchemaUnionTypes(value);
|
|
127
|
-
}
|
|
128
|
-
if (Array.isArray(result.type)) {
|
|
129
|
-
const nonNull = result.type.filter((t) => t !== "null");
|
|
130
|
-
if (nonNull.length === 1) {
|
|
131
|
-
result.type = nonNull[0];
|
|
132
|
-
} else if (nonNull.length === 0) {
|
|
133
|
-
result.type = "null";
|
|
134
|
-
} else {
|
|
135
|
-
// Multiple non-null types: express as anyOf, carrying items into the array branch
|
|
136
|
-
// so no branch is left itemless.
|
|
137
|
-
const items = result.items;
|
|
138
|
-
delete result.items;
|
|
139
|
-
result.anyOf = nonNull.map((t) =>
|
|
140
|
-
t === "array" && items != null ? { type: t, items } : { type: t }
|
|
141
|
-
);
|
|
142
|
-
delete result.type;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return result;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Normalize tool input schemas in a `tools/list` response so downstream providers
|
|
150
|
-
* (notably Gemini) accept them. No-op for any other response shape.
|
|
151
|
-
*/
|
|
152
|
-
function normalizeToolsListResponse(response: JsonRpcResponse | null): JsonRpcResponse | null {
|
|
153
|
-
if (!response || typeof response.result !== "object" || response.result === null) return response;
|
|
154
|
-
const result = response.result as { tools?: unknown };
|
|
155
|
-
if (!Array.isArray(result.tools)) return response;
|
|
156
|
-
const tools = result.tools.map((entry) => {
|
|
157
|
-
if (!entry || typeof entry !== "object") return entry;
|
|
158
|
-
const tool = entry as Record<string, unknown>;
|
|
159
|
-
if (tool.inputSchema == null || typeof tool.inputSchema !== "object") return tool;
|
|
160
|
-
return { ...tool, inputSchema: normalizeSchemaUnionTypes(tool.inputSchema) };
|
|
161
|
-
});
|
|
162
|
-
return { ...response, result: { ...result, tools } };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Apply response normalization that depends on the request method. */
|
|
166
|
-
function finalizeResponse(
|
|
167
|
-
request: JsonRpcRequest,
|
|
168
|
-
response: JsonRpcResponse | null
|
|
169
|
-
): JsonRpcResponse | null {
|
|
170
|
-
return request.method === "tools/list" ? normalizeToolsListResponse(response) : response;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function createBridge(opts: BridgeOptions): Bridge {
|
|
174
|
-
const remoteUrl = opts.remoteUrl;
|
|
175
|
-
const getToken = opts.getToken;
|
|
176
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
177
|
-
const ttl = opts.tokenCacheTtlMs ?? DEFAULT_TOKEN_CACHE_TTL_MS;
|
|
178
|
-
const now = opts.now ?? Date.now;
|
|
179
|
-
const log = opts.logError ?? ((m) => process.stderr.write(`${m}\n`));
|
|
180
|
-
|
|
181
|
-
let cachedToken: { value: string; fetchedAt: number } | null = null;
|
|
182
|
-
let sessionId: string | null = null;
|
|
183
|
-
|
|
184
|
-
async function token(force: boolean): Promise<string | null> {
|
|
185
|
-
if (!force && cachedToken && now() - cachedToken.fetchedAt < ttl) {
|
|
186
|
-
return cachedToken.value;
|
|
187
|
-
}
|
|
188
|
-
const value = await getToken();
|
|
189
|
-
if (value) {
|
|
190
|
-
cachedToken = { value, fetchedAt: now() };
|
|
191
|
-
} else {
|
|
192
|
-
cachedToken = null;
|
|
193
|
-
}
|
|
194
|
-
return value;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function errorResponse(
|
|
198
|
-
id: string | number | null | undefined,
|
|
199
|
-
code: number,
|
|
200
|
-
message: string
|
|
201
|
-
): JsonRpcResponse | null {
|
|
202
|
-
if (id === undefined || id === null) return null;
|
|
203
|
-
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function attempt(
|
|
207
|
-
request: JsonRpcRequest,
|
|
208
|
-
forceRefresh: boolean
|
|
209
|
-
): Promise<
|
|
210
|
-
| { kind: "ok"; response: JsonRpcResponse | null }
|
|
211
|
-
| { kind: "retry" }
|
|
212
|
-
| { kind: "err"; response: JsonRpcResponse | null }
|
|
213
|
-
> {
|
|
214
|
-
const bearer = await token(forceRefresh);
|
|
215
|
-
if (!bearer) {
|
|
216
|
-
return {
|
|
217
|
-
kind: "err",
|
|
218
|
-
response: errorResponse(
|
|
219
|
-
request.id,
|
|
220
|
-
-32000,
|
|
221
|
-
"envoy-dispatch shim: gh auth token returned empty — check your gh-app setup"
|
|
222
|
-
),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const headers: Record<string, string> = {
|
|
227
|
-
Authorization: `Bearer ${bearer}`,
|
|
228
|
-
"Content-Type": "application/json",
|
|
229
|
-
Accept: "application/json, text/event-stream",
|
|
230
|
-
};
|
|
231
|
-
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
232
|
-
|
|
233
|
-
let response: Response;
|
|
234
|
-
try {
|
|
235
|
-
response = await fetchImpl(remoteUrl, {
|
|
236
|
-
method: "POST",
|
|
237
|
-
headers,
|
|
238
|
-
body: JSON.stringify(request),
|
|
239
|
-
});
|
|
240
|
-
} catch (err) {
|
|
241
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
-
return {
|
|
243
|
-
kind: "err",
|
|
244
|
-
response: errorResponse(request.id, -32603, `envoy-dispatch shim network error: ${msg}`),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (response.status === 401 && !forceRefresh) {
|
|
249
|
-
return { kind: "retry" };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const respSession = response.headers.get("mcp-session-id");
|
|
253
|
-
if (respSession) sessionId = respSession;
|
|
254
|
-
|
|
255
|
-
if (!response.ok) {
|
|
256
|
-
const body = await response.text().catch(() => "");
|
|
257
|
-
return {
|
|
258
|
-
kind: "err",
|
|
259
|
-
response: errorResponse(
|
|
260
|
-
request.id,
|
|
261
|
-
-32603,
|
|
262
|
-
`envoy-dispatch shim: remote ${response.status} ${response.statusText} ${body.slice(0, 200)}`
|
|
263
|
-
),
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (request.id === undefined || request.id === null) {
|
|
268
|
-
// Notification — no response expected by JSON-RPC contract.
|
|
269
|
-
return { kind: "ok", response: null };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const body = await response.text();
|
|
273
|
-
const ct = response.headers.get("content-type") ?? "";
|
|
274
|
-
try {
|
|
275
|
-
const parsed = ct.includes("text/event-stream") ? parseSseBody(body) : JSON.parse(body);
|
|
276
|
-
if (parsed && typeof parsed === "object") {
|
|
277
|
-
if (!forceRefresh && hasUpstreamUnauthorized(parsed)) {
|
|
278
|
-
return { kind: "retry" };
|
|
279
|
-
}
|
|
280
|
-
return { kind: "ok", response: parsed as JsonRpcResponse };
|
|
281
|
-
}
|
|
282
|
-
return {
|
|
283
|
-
kind: "err",
|
|
284
|
-
response: errorResponse(
|
|
285
|
-
request.id,
|
|
286
|
-
-32603,
|
|
287
|
-
"envoy-dispatch shim: empty/invalid response body"
|
|
288
|
-
),
|
|
289
|
-
};
|
|
290
|
-
} catch (err) {
|
|
291
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
292
|
-
return {
|
|
293
|
-
kind: "err",
|
|
294
|
-
response: errorResponse(request.id, -32603, `envoy-dispatch shim: parse error: ${msg}`),
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
async handle(request: JsonRpcRequest): Promise<JsonRpcResponse | null> {
|
|
301
|
-
const first = await attempt(request, false);
|
|
302
|
-
if (first.kind === "ok" || first.kind === "err")
|
|
303
|
-
return finalizeResponse(request, first.response);
|
|
304
|
-
// retry once with forced refresh on 401
|
|
305
|
-
log("envoy-dispatch shim: 401 from remote, re-minting token and retrying once");
|
|
306
|
-
const second = await attempt(request, true);
|
|
307
|
-
return finalizeResponse(request, second.kind === "retry" ? null : second.response);
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
}
|