@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 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 dispatch MCP server (shim); `tool.execute.after` auto-subscribes the caller to the new thread's topic so answers route back (Dispatch AC#4) |
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
 
@@ -1,75 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- // envoy-plugin local MCP shim.
3
- //
4
- // Spawned by OpenCode as a `type: "local"` MCP transport. Reads JSON-RPC
5
- // messages from stdin (newline-delimited), forwards each to the remote
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
- import * as readline from "node:readline";
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "src/server.ts",
6
6
  "exports": {
@@ -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("test-machine");
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("uses 'unknown' for machine_id when HOSTNAME is not set", async () => {
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
- delete process.env.HOSTNAME;
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: "ses_no_hostname",
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("unknown");
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
+ });
@@ -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 = error instanceof Error ? error.message : String(error);
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 (Dispatch AC#4).
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
- for (const line of output.split("\n")) {
37
- if (!line.includes(`pid=${process.pid}`)) continue;
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
- `[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
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
- // Dispatch AC#4: when this session opens a Dispatch thread via the
222
- // envoy_dispatch MCP tool, auto-subscribe it to the thread's GitHub topic
223
- // so the human's reply is delivered back through Envoy. Best-effort — a
224
- // subscribe failure must never surface to the model or fail the tool call.
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
- logger.warn(
238
- `[envoy-plugin] dispatch auto-subscribe failed: ${err instanceof Error ? err.message : String(err)}`
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: process.env.HOSTNAME || "unknown",
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
- const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
40
- for (const line of output.split("\n")) {
41
- if (!line.includes(`pid=${pid}`)) continue;
42
- const parts = line.trim().split(/\s+/);
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
@@ -16,5 +16,5 @@
16
16
  "jsx": "preserve",
17
17
  "types": ["bun"]
18
18
  },
19
- "include": ["src/**/*.ts", "src/**/*.tsx"]
19
+ "include": ["bin/**/*.ts", "src/**/*.ts", "src/**/*.tsx"]
20
20
  }
@@ -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
- }