@sjawhar/opencode-legion-envoy 0.5.2 → 0.6.1

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.
@@ -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
- }
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "Bundler",
6
- "baseUrl": ".",
7
- "paths": {
8
- "@legion/contracts": ["../contracts/src/index.ts"],
9
- "@legion/envoy-client/defaults": ["../envoy-client/src/defaults.ts"],
10
- "@legion/envoy-client/tool-contract": ["../envoy-client/src/tool-contract.ts"],
11
- "@legion/envoy-client/transport": ["../envoy-client/src/transport.ts"]
12
- },
13
- "strict": true,
14
- "noEmit": true,
15
- "skipLibCheck": true,
16
- "jsx": "preserve",
17
- "types": ["bun"]
18
- },
19
- "include": ["src/**/*.ts", "src/**/*.tsx"]
20
- }