@alfe.ai/github-mcp 0.3.18 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/dist/server.js +584 -245
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  GitHub MCP proxy server — bridges the official @modelcontextprotocol/server-github with Alfe OAuth credentials (Pattern A multi-account)
4
4
 
5
+ The proxy starts one pinned child process per connected GitHub account.
6
+ Every provider tool requires the lowercase `login` returned by
7
+ `github_list_accounts`, so the caller chooses the exact OAuth identity
8
+ instead of relying on an implicit default. `github_check_connection`
9
+ verifies both token validity and token-to-login identity.
10
+
11
+ The child receives only its GitHub token and a small runtime/TLS/proxy
12
+ environment allowlist. Tool catalogs, arguments, results, health probes,
13
+ errors, startup, calls, and shutdown are validated and bounded at the
14
+ proxy boundary. With no connected accounts the server remains available
15
+ in discovery-only mode.
16
+
5
17
  Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: build, deploy, and run agents with persistent memory, identity, integrations, and channels. See the [documentation](https://docs.alfe.ai) to get started.
6
18
 
7
19
  ## Install
@@ -10,6 +22,10 @@ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: buil
10
22
  npm install @alfe.ai/github-mcp
11
23
  ```
12
24
 
25
+ The production integration invokes this package through `npx`; direct
26
+ installation is primarily useful for package development. Agent API
27
+ configuration must already be present in `~/.alfe/config.toml`.
28
+
13
29
  ## Links
14
30
 
15
31
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -1,308 +1,647 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
8
  import { resolveConfig } from "@alfe.ai/config";
8
9
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
10
  import { assertPatternA } from "@alfe.ai/mcp-bundler";
11
+ import { createHash } from "node:crypto";
12
+ const MAX_FORWARDED_ARGUMENT_BYTES = 2 * 1024 * 1024;
13
+ const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
14
+ const MAX_SCHEMA_BYTES = 512 * 1024;
15
+ const MAX_CHECK_RESPONSE_BYTES = 64 * 1024;
16
+ const MAX_JSON_DEPTH = 12;
17
+ const MAX_JSON_NODES = 3e4;
18
+ const MAX_JSON_ARRAY_ITEMS = 2e3;
19
+ const MAX_ARGUMENT_STRING_CHARS = 15e5;
20
+ const MAX_TOKEN_CHARS = 16384;
21
+ const MAX_ERROR_CHARS = 2048;
22
+ const GITHUB_CHECK_TIMEOUT_MS = 15e3;
23
+ const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
24
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
25
+ const UNSAFE_KEYS = new Set([
26
+ "__proto__",
27
+ "constructor",
28
+ "prototype"
29
+ ]);
30
+ const RESERVED_TOOLS = new Set(["github_list_accounts", "github_check_connection"]);
31
+ const CHILD_ENV_ALLOWLIST = [
32
+ "PATH",
33
+ "HOME",
34
+ "TMPDIR",
35
+ "TMP",
36
+ "TEMP",
37
+ "LANG",
38
+ "LC_ALL",
39
+ "LC_CTYPE",
40
+ "TZ",
41
+ "SSL_CERT_FILE",
42
+ "SSL_CERT_DIR",
43
+ "NODE_EXTRA_CA_CERTS",
44
+ "HTTP_PROXY",
45
+ "HTTPS_PROXY",
46
+ "NO_PROXY",
47
+ "http_proxy",
48
+ "https_proxy",
49
+ "no_proxy",
50
+ "NPM_CONFIG_CACHE",
51
+ "XDG_CACHE_HOME"
52
+ ];
53
+ const READ_ONLY_TOOLS = new Set([
54
+ "search_repositories",
55
+ "get_file_contents",
56
+ "list_commits",
57
+ "list_issues",
58
+ "search_code",
59
+ "search_issues",
60
+ "search_users",
61
+ "get_issue",
62
+ "get_pull_request",
63
+ "list_pull_requests",
64
+ "get_pull_request_files",
65
+ "get_pull_request_status",
66
+ "get_pull_request_comments",
67
+ "get_pull_request_reviews"
68
+ ]);
69
+ const DESTRUCTIVE_TOOLS = new Set(["merge_pull_request"]);
70
+ function buildChildEnvironment(accessToken, source = process.env) {
71
+ const token = validateAccessToken(accessToken);
72
+ const environment = Object.create(null);
73
+ for (const name of CHILD_ENV_ALLOWLIST) {
74
+ const value = source[name];
75
+ if (value !== void 0 && !containsControlCharacter(value)) environment[name] = value;
76
+ }
77
+ return {
78
+ ...environment,
79
+ GITHUB_PERSONAL_ACCESS_TOKEN: token
80
+ };
81
+ }
82
+ function normalizeGitHubAccount(value) {
83
+ if (!isRecord(value)) throw new Error("GitHub account must be an object");
84
+ return {
85
+ connectionId: validateText("connectionId", value.connectionId, 128),
86
+ displayName: value.displayName === null ? null : validateText("display name", value.displayName, 256),
87
+ connectedAt: validateConnectedAt(value.connectedAt),
88
+ accessToken: validateAccessToken(value.accessToken),
89
+ login: normalizeGitHubLogin(value.login)
90
+ };
91
+ }
92
+ function assertAccountCount(value) {
93
+ if (!Array.isArray(value)) throw new Error("GitHub accounts response must be an array");
94
+ if (value.length > 50) throw new Error(`GitHub accounts response exceeds ${String(50)} accounts`);
95
+ }
96
+ function injectLoginSelector(tool) {
97
+ const name = validateToolName(tool.name);
98
+ if (RESERVED_TOOLS.has(name)) throw new Error(`Child tool collides with reserved proxy tool: ${name}`);
99
+ const original = cloneJsonRecord("tool input schema", tool.inputSchema, MAX_SCHEMA_BYTES, 1e5);
100
+ const propertiesValue = Reflect.get(original, "properties");
101
+ const originalProperties = propertiesValue === void 0 ? Object.create(null) : cloneJsonRecord("tool schema properties", propertiesValue, MAX_SCHEMA_BYTES, 1e5);
102
+ const originalRequired = validateRequired(Reflect.get(original, "required"));
103
+ const result = {
104
+ name,
105
+ inputSchema: {
106
+ ...original,
107
+ type: "object",
108
+ properties: {
109
+ ...originalProperties,
110
+ login: {
111
+ type: "string",
112
+ pattern: LOGIN_PATTERN.source,
113
+ maxLength: 39,
114
+ description: "Lowercase GitHub username from github_list_accounts. This required selector chooses the exact connected OAuth identity; owner/repo still choose the repository target."
115
+ }
116
+ },
117
+ required: [...new Set(["login", ...originalRequired])]
118
+ }
119
+ };
120
+ if (tool.title !== void 0) result.title = validateText("tool title", tool.title, 256);
121
+ if (tool.description !== void 0) result.description = validateText("tool description", tool.description, 2e4);
122
+ if (tool.outputSchema !== void 0) result.outputSchema = cloneJsonRecord("tool output schema", tool.outputSchema, MAX_SCHEMA_BYTES, 1e5);
123
+ if (tool.annotations !== void 0) result.annotations = validateAnnotations(tool.annotations);
124
+ else result.annotations = {
125
+ readOnlyHint: READ_ONLY_TOOLS.has(name),
126
+ destructiveHint: DESTRUCTIVE_TOOLS.has(name),
127
+ openWorldHint: true
128
+ };
129
+ if (tool.execution !== void 0) result.execution = validateExecution(tool.execution);
130
+ return result;
131
+ }
132
+ function validateToolCatalog(tools) {
133
+ if (!Array.isArray(tools)) throw new Error("Child tool catalog must be an array");
134
+ if (tools.length > 100) throw new Error(`Child tool catalog exceeds ${String(100)} tools`);
135
+ const names = /* @__PURE__ */ new Set();
136
+ return tools.map((tool) => {
137
+ if (!isRecord(tool)) throw new Error("Child tool descriptor must be an object");
138
+ const proxied = injectLoginSelector(tool);
139
+ if (names.has(proxied.name)) throw new Error(`Child tool catalog contains duplicate name: ${proxied.name}`);
140
+ names.add(proxied.name);
141
+ return proxied;
142
+ });
143
+ }
144
+ function prepareForwardedArguments(value) {
145
+ const cloned = cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
146
+ const loginValue = cloned.login;
147
+ if (typeof loginValue !== "string") throw new Error("login must be a string");
148
+ const login = normalizeGitHubLogin(loginValue);
149
+ const { login: _selector, ...forwarded } = cloned;
150
+ return {
151
+ login,
152
+ forwarded
153
+ };
154
+ }
155
+ function assertBoundedToolArguments(value) {
156
+ return cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
157
+ }
158
+ function assertBoundedToolResult(value) {
159
+ assertJsonBudget("child tool result", value, MAX_TOOL_RESULT_BYTES, MAX_TOOL_RESULT_BYTES);
160
+ return value;
161
+ }
162
+ function extractToolErrorText(value) {
163
+ if (!isRecord(value) || !Array.isArray(value.content)) return "";
164
+ return value.content.slice(0, 100).map((content) => isRecord(content) && typeof content.text === "string" ? content.text.slice(0, MAX_ERROR_CHARS) : "").join("\n").slice(0, MAX_ERROR_CHARS);
165
+ }
166
+ async function checkGitHubConnection(accessToken, fetchFn = fetch) {
167
+ const response = await fetchFn("https://api.github.com/user", {
168
+ headers: {
169
+ Authorization: `Bearer ${validateAccessToken(accessToken)}`,
170
+ Accept: "application/vnd.github+json",
171
+ "X-GitHub-Api-Version": "2022-11-28",
172
+ "User-Agent": "alfe-github-mcp"
173
+ },
174
+ redirect: "error",
175
+ signal: AbortSignal.timeout(GITHUB_CHECK_TIMEOUT_MS)
176
+ });
177
+ if (!response.ok) {
178
+ await response.body?.cancel().catch(() => void 0);
179
+ return {
180
+ ok: false,
181
+ status: response.status
182
+ };
183
+ }
184
+ const text = await readResponseText(response, MAX_CHECK_RESPONSE_BYTES);
185
+ const parsed = JSON.parse(text);
186
+ if (!isRecord(parsed)) throw new Error("GitHub /user response must be an object");
187
+ const id = parsed.id;
188
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 1) throw new Error("GitHub /user response contains an invalid id");
189
+ const name = parsed.name;
190
+ return {
191
+ ok: true,
192
+ status: response.status,
193
+ user: {
194
+ login: normalizeGitHubLogin(parsed.login),
195
+ id,
196
+ name: name === null || name === void 0 ? null : validateText("GitHub user name", name, 256)
197
+ }
198
+ };
199
+ }
200
+ function normalizeGitHubLogin(value) {
201
+ if (typeof value !== "string" || !LOGIN_PATTERN.test(value)) throw new Error("login must be a valid GitHub username of at most 39 characters");
202
+ return value.toLowerCase();
203
+ }
204
+ function safeErrorMessage(error, secrets = []) {
205
+ let message = error instanceof Error ? error.message : String(error);
206
+ for (const secret of secrets) if (secret.length > 0) message = message.split(secret).join("[REDACTED]");
207
+ return message.replace(/Bearer\s+[^\s,;]+/giu, "Bearer [REDACTED]").replace(/(?:gh[opusr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,})/gu, "[REDACTED_GITHUB_TOKEN]").replace(/alfe_(?:dev|test|demo|live)_[a-f0-9]{16,}/giu, "[REDACTED_ALFE_KEY]").slice(0, MAX_ERROR_CHARS);
208
+ }
209
+ function connectionLogId(value) {
210
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
211
+ }
212
+ async function withDeadline(promise, timeoutMs, label) {
213
+ let timer;
214
+ try {
215
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
216
+ timer = setTimeout(() => {
217
+ reject(/* @__PURE__ */ new Error(`${label} timed out`));
218
+ }, timeoutMs);
219
+ timer.unref();
220
+ })]);
221
+ } finally {
222
+ if (timer !== void 0) clearTimeout(timer);
223
+ }
224
+ }
225
+ function validateAccessToken(value) {
226
+ if (typeof value !== "string" || value.length < 1 || value.length > MAX_TOKEN_CHARS || containsControlCharacter(value)) throw new Error("GitHub access token is invalid");
227
+ return value;
228
+ }
229
+ function validateConnectedAt(value) {
230
+ const validated = validateText("connectedAt", value, 64);
231
+ const parsed = new Date(validated);
232
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== validated) throw new Error("connectedAt must be a canonical ISO date-time");
233
+ return validated;
234
+ }
235
+ function validateToolName(value) {
236
+ if (typeof value !== "string" || !TOOL_NAME_PATTERN.test(value)) throw new Error("Child tool name is invalid");
237
+ return value;
238
+ }
239
+ function validateText(label, value, maxChars) {
240
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
241
+ return value;
242
+ }
243
+ function validateRequired(value) {
244
+ if (value === void 0) return [];
245
+ if (!Array.isArray(value) || value.length > 100) throw new Error("tool schema required must be an array");
246
+ const result = [];
247
+ for (const entry of value) {
248
+ if (typeof entry !== "string" || entry.length < 1 || entry.length > 128) throw new Error("tool schema required contains an invalid property name");
249
+ if (!result.includes(entry)) result.push(entry);
250
+ }
251
+ return result;
252
+ }
253
+ function validateAnnotations(value) {
254
+ if (!isRecord(value)) throw new Error("tool annotations must be an object");
255
+ const result = {};
256
+ const title = value.title;
257
+ if (title !== void 0) result.title = validateText("tool annotation title", title, 256);
258
+ for (const key of [
259
+ "readOnlyHint",
260
+ "destructiveHint",
261
+ "idempotentHint",
262
+ "openWorldHint"
263
+ ]) {
264
+ const hint = value[key];
265
+ if (hint !== void 0) {
266
+ if (typeof hint !== "boolean") throw new Error(`tool annotation ${key} must be boolean`);
267
+ result[key] = hint;
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+ function validateExecution(value) {
273
+ if (!isRecord(value)) throw new Error("tool execution metadata must be an object");
274
+ const taskSupport = Reflect.get(value, "taskSupport");
275
+ if (taskSupport !== void 0 && taskSupport !== "optional" && taskSupport !== "required" && taskSupport !== "forbidden") throw new Error("tool execution taskSupport is invalid");
276
+ return taskSupport === void 0 ? {} : { taskSupport };
277
+ }
278
+ async function readResponseText(response, maxBytes) {
279
+ const declared = response.headers.get("content-length");
280
+ if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > maxBytes)) {
281
+ await response.body?.cancel().catch(() => void 0);
282
+ throw new Error("GitHub /user response exceeds the byte limit");
283
+ }
284
+ if (response.body === null) return "";
285
+ const reader = response.body.getReader();
286
+ const decoder = new TextDecoder();
287
+ let bytes = 0;
288
+ let text = "";
289
+ try {
290
+ for (;;) {
291
+ const readResult = await reader.read();
292
+ if (readResult.done) break;
293
+ const chunk = readResult.value;
294
+ if (chunk === void 0) throw new Error("GitHub /user response stream returned no bytes");
295
+ bytes += chunk.byteLength;
296
+ if (bytes > maxBytes) {
297
+ await reader.cancel("response too large").catch(() => void 0);
298
+ throw new Error("GitHub /user response exceeds the byte limit");
299
+ }
300
+ text += decoder.decode(chunk, { stream: true });
301
+ }
302
+ text += decoder.decode();
303
+ return text;
304
+ } finally {
305
+ reader.releaseLock();
306
+ }
307
+ }
308
+ function cloneJsonRecord(label, value, maxBytes, maxStringChars) {
309
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
310
+ const cloned = cloneRecord(label, value, 0, { nodes: 0 }, maxStringChars);
311
+ if (Buffer.byteLength(JSON.stringify(cloned), "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
312
+ return cloned;
313
+ }
314
+ function assertJsonBudget(label, value, maxBytes, maxStringChars) {
315
+ cloneJson(label, value, 0, { nodes: 0 }, maxStringChars);
316
+ let encoded;
317
+ try {
318
+ encoded = JSON.stringify(value);
319
+ } catch {
320
+ throw new Error(`${label} must be JSON serializable`);
321
+ }
322
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
323
+ }
324
+ function cloneJson(label, value, depth, budget, maxStringChars) {
325
+ budget.nodes += 1;
326
+ if (budget.nodes > MAX_JSON_NODES) throw new Error(`${label} contains too many values`);
327
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
328
+ if (value === null || typeof value === "boolean") return value;
329
+ if (typeof value === "number") {
330
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
331
+ return value;
332
+ }
333
+ if (typeof value === "string") {
334
+ if (value.length > maxStringChars) throw new Error(`${label} contains an oversized string`);
335
+ return value;
336
+ }
337
+ if (Array.isArray(value)) {
338
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error(`${label} contains an oversized array`);
339
+ return value.map((entry) => cloneJson(label, entry, depth + 1, budget, maxStringChars));
340
+ }
341
+ if (!isRecord(value)) throw new Error(`${label} contains a non-JSON value`);
342
+ return cloneRecord(label, value, depth, budget, maxStringChars);
343
+ }
344
+ function cloneRecord(label, value, depth, budget, maxStringChars) {
345
+ const result = Object.create(null);
346
+ for (const [key, nested] of Object.entries(value)) {
347
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains an unsafe property name`);
348
+ result[key] = cloneJson(label, nested, depth + 1, budget, maxStringChars);
349
+ }
350
+ return result;
351
+ }
352
+ function isRecord(value) {
353
+ return typeof value === "object" && value !== null && !Array.isArray(value);
354
+ }
355
+ function containsControlCharacter(value) {
356
+ return Array.from(value).some((character) => {
357
+ const codePoint = character.codePointAt(0) ?? 0;
358
+ return codePoint < 32 || codePoint === 127;
359
+ });
360
+ }
361
+ //#endregion
10
362
  //#region src/server.ts
11
363
  /**
12
364
  * GitHub MCP Proxy Server (Pattern A multi-account)
13
365
  *
14
- * A thin proxy that fans out to one official
15
- * `@modelcontextprotocol/server-github` child process per connected
16
- * GitHub account. Every credential-touching tool requires the LLM to
17
- * pass a `login` selector arg (the GitHub username); the proxy strips
18
- * that arg, dispatches to the right child, and returns the result.
19
- *
20
- * Pattern A locked in PR 7-deferred slice 2 of channels-and-credential-
21
- * driven-integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
22
- * contract; `services/connect/DEVELOPING.md` for the provider table.
23
- *
24
- * Architecture:
25
- * OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × server-github
26
- *
27
- * Token model:
28
- * GitHub OAuth App tokens have no expiry (`tokenLifecycle: "no_expiry"`
29
- * in services/connect's GitHub provider). There is intentionally no
30
- * token-refresh path — a revoked token surfaces as a 401 on the next
31
- * call and the LLM can prompt the user to reconnect.
32
- *
33
- * Known caveat — child dependency:
34
- * `@modelcontextprotocol/server-github` is marked deprecated upstream
35
- * ("Package no longer supported"). It still works at npm install
36
- * time and is the same dependency the existing GitHub integration
37
- * manifest invoked directly. Migrating to a native @octokit/rest
38
- * tool surface is tracked as a v2 follow-up.
39
- *
40
- * Uses the low-level Server class (not McpServer) because child tools
41
- * return JSON Schema objects — McpServer.registerTool requires Zod.
366
+ * OpenClaw communicates with this process over stdio. The proxy owns one
367
+ * pinned `@modelcontextprotocol/server-github` child per connected GitHub
368
+ * identity and injects a required, lowercase `login` selector into every
369
+ * credential-touching tool.
42
370
  */
371
+ const packageMetadata = createRequire(import.meta.url)("../package.json");
372
+ const CHILD_PACKAGE = "@modelcontextprotocol/server-github";
373
+ const childVersion = packageMetadata.dependencies?.[CHILD_PACKAGE];
374
+ if (childVersion === void 0 || !/^\d{4}\.\d+\.\d+$/u.test(childVersion)) throw new Error(`${CHILD_PACKAGE} must have one exact calendar-version dependency`);
375
+ const CHILD_SPECIFIER = `${CHILD_PACKAGE}@${childVersion}`;
376
+ const CHILD_START_TIMEOUT_MS = 3e4;
377
+ const CHILD_CATALOG_TIMEOUT_MS = 15e3;
378
+ const CHILD_CALL_TIMEOUT_MS = 6e4;
379
+ const CHILD_CLOSE_TIMEOUT_MS = 5e3;
43
380
  const accounts = /* @__PURE__ */ new Map();
44
- /**
45
- * Full snapshot of every GitHub connection returned by getGithubAccounts(),
46
- * including ones we couldn't spawn a child for (e.g. missing access token
47
- * or spawn failure). `github_list_accounts` returns this so the LLM can
48
- * surface partial-failure connections instead of silently dropping them.
49
- */
381
+ const claimedLogins = /* @__PURE__ */ new Map();
50
382
  const allAccountsSnapshot = [];
51
- /** Cached, selector-injected tool list returned to OpenClaw. */
383
+ const catalogToolNames = /* @__PURE__ */ new Set();
52
384
  let cachedTools = [];
53
- function log(msg) {
54
- process.stderr.write(`[github-mcp-proxy] ${msg}\n`);
385
+ let proxyServer = null;
386
+ let shutdownPromise = null;
387
+ function log(message) {
388
+ process.stderr.write(`[github-mcp-proxy] ${message}\n`);
389
+ }
390
+ function errorResult(error, context = {}, secrets = []) {
391
+ return {
392
+ content: [{
393
+ type: "text",
394
+ text: JSON.stringify({
395
+ ...context,
396
+ error: safeErrorMessage(error, secrets)
397
+ })
398
+ }],
399
+ isError: true
400
+ };
55
401
  }
56
402
  function resolveAccount(login) {
57
- if (!login) throw new Error("Missing required login argument. Call github_list_accounts to see the connected GitHub accounts and pass the login you want to target.");
58
- const acct = accounts.get(login);
59
- if (!acct) {
60
- const known = allAccountsSnapshot.find((s) => s.login === login);
61
- if (known && !known.connected) throw new Error(`login ${login} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this GitHub account from the dashboard.`);
62
- throw new Error(`Unknown login: ${login}. Call github_list_accounts to see the connected GitHub accounts on this agent.`);
63
- }
64
- return acct;
403
+ const account = accounts.get(login);
404
+ if (account !== void 0) return account;
405
+ const known = allAccountsSnapshot.find((summary) => summary.login === login);
406
+ if (known !== void 0) throw new Error(`login ${login} is connected on this agent but its credential process is unavailable (${known.reason ?? "unknown"}). Ask the user to reconnect this GitHub account from the dashboard.`);
407
+ throw new Error(`Unknown login: ${login}. Call github_list_accounts to see the connected GitHub accounts on this agent.`);
408
+ }
409
+ async function closeClient(client) {
410
+ await withDeadline(client.close(), CHILD_CLOSE_TIMEOUT_MS, "GitHub child close").catch(() => void 0);
65
411
  }
66
- /**
67
- * Pin the child server-github version to match `package.json` so a future
68
- * upstream republish (or removal — the package is deprecated) can't
69
- * silently rotate the child without our knowledge. `package.json` and
70
- * this string must stay in sync; the openclaw-github CI runs
71
- * `pnpm run pin:check` (TODO post-v0.1.0) to catch drift.
72
- */
73
- const CHILD_SERVER_GITHUB_VERSION = "2025.4.8";
74
412
  async function spawnChild(accessToken) {
75
413
  const transport = new StdioClientTransport({
76
414
  command: "npx",
77
- args: ["-y", `@modelcontextprotocol/server-github@${CHILD_SERVER_GITHUB_VERSION}`],
78
- env: {
79
- ...process.env,
80
- GITHUB_PERSONAL_ACCESS_TOKEN: accessToken
81
- }
415
+ args: ["-y", CHILD_SPECIFIER],
416
+ env: buildChildEnvironment(accessToken)
82
417
  });
83
418
  const client = new Client({
84
419
  name: "github-mcp-proxy",
85
- version: "0.1.0"
420
+ version: packageMetadata.version
86
421
  });
87
- await client.connect(transport);
88
- return client;
422
+ try {
423
+ await withDeadline(client.connect(transport), CHILD_START_TIMEOUT_MS, "GitHub child startup");
424
+ return client;
425
+ } catch (error) {
426
+ await closeClient(client);
427
+ throw error;
428
+ }
89
429
  }
90
- async function killAllChildren() {
91
- for (const acct of accounts.values()) try {
92
- await acct.client.close();
93
- } catch {}
430
+ async function closeAllChildren() {
431
+ const current = [...accounts.values()];
94
432
  accounts.clear();
433
+ await Promise.all(current.map(async ({ client }) => closeClient(client)));
95
434
  }
96
- /**
97
- * Take a tool descriptor from the child GitHub MCP server and inject a
98
- * required `login` string property into its inputSchema. The proxy
99
- * strips this arg before forwarding the call.
100
- */
101
- function injectLoginSelector(tool) {
102
- const original = tool.inputSchema ?? {};
103
- const originalProperties = original.properties ?? {};
104
- const originalRequired = Array.isArray(original.required) ? original.required : [];
105
- const injectedProperties = {
106
- ...originalProperties,
107
- login: {
108
- type: "string",
109
- description: "GitHub username (login) — use the value from github_list_accounts to pick which connected account this call should target. Tool-level `owner` arguments still target a specific repo owner; `login` selects which OAuth identity makes the API call."
110
- }
111
- };
112
- const injectedRequired = originalRequired.includes("login") ? originalRequired : ["login", ...originalRequired];
113
- return {
114
- name: tool.name,
115
- description: tool.description,
116
- inputSchema: {
117
- ...original,
118
- type: original.type ?? "object",
119
- properties: injectedProperties,
120
- required: injectedRequired
435
+ async function discoverChildCatalog() {
436
+ for (const account of accounts.values()) try {
437
+ const tools = validateToolCatalog((await withDeadline(account.client.listTools(), CHILD_CATALOG_TIMEOUT_MS, "GitHub child tool discovery")).tools);
438
+ for (const tool of tools) catalogToolNames.add(tool.name);
439
+ return tools;
440
+ } catch (error) {
441
+ log(`Tool discovery failed for connection ${connectionLogId(account.connectionId)}: ${safeErrorMessage(error, [account.accessToken])}`);
442
+ }
443
+ return [];
444
+ }
445
+ function appendLocalTools(tools) {
446
+ return [
447
+ ...tools,
448
+ {
449
+ name: "github_list_accounts",
450
+ description: "List every connected GitHub account and its local proxy readiness. Use the lowercase login value as the selector on every other GitHub tool.",
451
+ inputSchema: {
452
+ type: "object",
453
+ properties: {},
454
+ additionalProperties: false
455
+ },
456
+ annotations: {
457
+ readOnlyHint: true,
458
+ destructiveHint: false,
459
+ idempotentHint: true,
460
+ openWorldHint: false
461
+ }
462
+ },
463
+ {
464
+ name: "github_check_connection",
465
+ description: "Verify that one selected GitHub OAuth token is valid and still belongs to the expected login. Revoked or identity-mismatched tokens must be reconnected.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: { login: {
469
+ type: "string",
470
+ maxLength: 39,
471
+ description: "Lowercase GitHub username from github_list_accounts."
472
+ } },
473
+ required: ["login"],
474
+ additionalProperties: false
475
+ },
476
+ annotations: {
477
+ readOnlyHint: true,
478
+ destructiveHint: false,
479
+ idempotentHint: true,
480
+ openWorldHint: true
481
+ }
121
482
  }
122
- };
483
+ ];
123
484
  }
124
- async function main() {
125
- const config = resolveConfig();
126
- const { accounts: connected } = await new AgentApiClient({
127
- apiKey: config.apiKey,
128
- apiUrl: config.apiUrl
129
- }).getGithubAccounts();
130
- if (connected.length === 0) log("No GitHub accounts connected — proxy will start with github_list_accounts and github_check_connection only");
131
- for (const acct of connected) {
132
- if (!acct.accessToken) {
133
- log(`Skipping account ${acct.login} no access token`);
485
+ async function loadAccounts(apiClient) {
486
+ const response = await apiClient.getGithubAccounts();
487
+ assertAccountCount(response.accounts);
488
+ if (response.accounts.length === 0) log("No GitHub accounts connected; starting in discovery-only mode");
489
+ for (const rawAccount of response.accounts) {
490
+ let account;
491
+ try {
492
+ account = normalizeGitHubAccount(rawAccount);
493
+ } catch (error) {
494
+ log(`Rejected invalid GitHub connection ${typeof rawAccount.connectionId === "string" ? connectionLogId(rawAccount.connectionId) : "invalid"}: ${safeErrorMessage(error)}`);
134
495
  allAccountsSnapshot.push({
135
- login: acct.login,
136
- displayName: acct.displayName,
137
- connectedAt: acct.connectedAt,
496
+ login: null,
497
+ displayName: null,
498
+ connectedAt: null,
138
499
  connected: false,
139
- reason: "missing_access_token"
500
+ reason: "invalid_account"
140
501
  });
141
502
  continue;
142
503
  }
143
- if (accounts.has(acct.login)) {
144
- log(`Duplicate login ${acct.login} returned by getGithubAccounts() — keeping the first cached child`);
504
+ if (claimedLogins.has(account.login)) {
505
+ log(`Rejected duplicate GitHub login from connection ${connectionLogId(account.connectionId)}`);
506
+ allAccountsSnapshot.push({
507
+ login: account.login,
508
+ displayName: account.displayName,
509
+ connectedAt: account.connectedAt,
510
+ connected: false,
511
+ reason: "duplicate_login"
512
+ });
145
513
  continue;
146
514
  }
515
+ claimedLogins.set(account.login, account.connectionId);
147
516
  try {
148
- const client = await spawnChild(acct.accessToken);
149
- accounts.set(acct.login, {
150
- login: acct.login,
151
- displayName: acct.displayName,
152
- accessToken: acct.accessToken,
517
+ const client = await spawnChild(account.accessToken);
518
+ accounts.set(account.login, {
519
+ ...account,
153
520
  client
154
521
  });
155
522
  allAccountsSnapshot.push({
156
- login: acct.login,
157
- displayName: acct.displayName,
158
- connectedAt: acct.connectedAt,
523
+ login: account.login,
524
+ displayName: account.displayName,
525
+ connectedAt: account.connectedAt,
159
526
  connected: true
160
527
  });
161
- log(`Spawned child server for account ${acct.login} (${acct.displayName ?? "unnamed"})`);
162
- } catch (err) {
163
- const message = err instanceof Error ? err.message : String(err);
164
- log(`Failed to spawn child for account ${acct.login}: ${message}`);
528
+ log(`Spawned child for connection ${connectionLogId(account.connectionId)}`);
529
+ } catch (error) {
530
+ log(`Child startup failed for connection ${connectionLogId(account.connectionId)}: ${safeErrorMessage(error, [account.accessToken])}`);
165
531
  allAccountsSnapshot.push({
166
- login: acct.login,
167
- displayName: acct.displayName,
168
- connectedAt: acct.connectedAt,
532
+ login: account.login,
533
+ displayName: account.displayName,
534
+ connectedAt: account.connectedAt,
169
535
  connected: false,
170
- reason: `spawn_failed: ${message}`
536
+ reason: "spawn_failed"
171
537
  });
172
538
  }
173
539
  }
174
- const firstAccount = accounts.values().next().value;
175
- if (firstAccount) {
176
- const { tools } = await firstAccount.client.listTools();
177
- cachedTools = tools.map(injectLoginSelector);
178
- log(`Child MCP server provides ${String(cachedTools.length)} tools (selector injected)`);
179
- } else {
180
- cachedTools = [];
181
- log("No child server available — only github_list_accounts and github_check_connection will be exposed");
540
+ }
541
+ async function handleToolCall(request) {
542
+ const { name } = request.params;
543
+ const args = request.params.arguments ?? {};
544
+ if (name === "github_list_accounts") {
545
+ const bounded = assertBoundedToolArguments(args);
546
+ if (Object.keys(bounded).length !== 0) return errorResult("github_list_accounts takes no arguments");
547
+ return { content: [{
548
+ type: "text",
549
+ text: JSON.stringify({ accounts: allAccountsSnapshot }, null, 2)
550
+ }] };
551
+ }
552
+ if (name === "github_check_connection") try {
553
+ const { login, forwarded } = prepareForwardedArguments(args);
554
+ if (Object.keys(forwarded).length !== 0) throw new Error("github_check_connection accepts only login");
555
+ const result = await checkGitHubConnection(resolveAccount(login).accessToken);
556
+ if (!result.ok) return errorResult("Token may have been revoked. Ask the user to reconnect this GitHub account from the dashboard.", {
557
+ login,
558
+ connected: false,
559
+ status: result.status
560
+ });
561
+ if (result.user?.login !== login) return errorResult("The credential belongs to a different GitHub login. Reconnect this account before using it.", {
562
+ login,
563
+ connected: false,
564
+ reason: "identity_mismatch"
565
+ });
566
+ return { content: [{
567
+ type: "text",
568
+ text: JSON.stringify({
569
+ login,
570
+ connected: true,
571
+ user: result.user
572
+ })
573
+ }] };
574
+ } catch (error) {
575
+ return errorResult(error);
182
576
  }
183
- cachedTools.push({
184
- name: "github_list_accounts",
185
- description: "List the GitHub accounts the agent has connected. Returns one entry per OAuth connection — use the returned login values as the `login` selector arg on every other GitHub tool.",
186
- inputSchema: {
187
- type: "object",
188
- properties: {}
189
- }
190
- });
191
- cachedTools.push({
192
- name: "github_check_connection",
193
- description: "Verify the GitHub OAuth connection for a specific connected account is still valid. Use this if GitHub API calls are failing with authentication errors. GitHub OAuth App tokens don't expire, so a failure here means the token was revoked.",
194
- inputSchema: {
195
- type: "object",
196
- properties: { login: {
197
- type: "string",
198
- description: "GitHub username (login) — use the value from github_list_accounts."
199
- } },
200
- required: ["login"]
201
- }
202
- });
203
- assertPatternA(cachedTools.map((t) => ({
204
- name: t.name,
205
- parameters: t.inputSchema
577
+ if (!catalogToolNames.has(name)) return errorResult(`Unknown GitHub tool: ${name}`);
578
+ let account;
579
+ let forwarded;
580
+ try {
581
+ const prepared = prepareForwardedArguments(args);
582
+ account = resolveAccount(prepared.login);
583
+ forwarded = prepared.forwarded;
584
+ } catch (error) {
585
+ return errorResult(error);
586
+ }
587
+ try {
588
+ const result = await withDeadline(account.client.callTool({
589
+ name,
590
+ arguments: forwarded
591
+ }), CHILD_CALL_TIMEOUT_MS, `GitHub ${name}`);
592
+ assertBoundedToolResult(result);
593
+ if (result.isError === true) return errorResult(extractToolErrorText(result) || "GitHub child tool returned an error", {
594
+ login: account.login,
595
+ tool: name
596
+ }, [account.accessToken]);
597
+ return result;
598
+ } catch (error) {
599
+ return errorResult(error, {
600
+ login: account.login,
601
+ tool: name
602
+ }, [account.accessToken]);
603
+ }
604
+ }
605
+ async function shutdown() {
606
+ if (shutdownPromise !== null) return shutdownPromise;
607
+ shutdownPromise = (async () => {
608
+ const server = proxyServer;
609
+ proxyServer = null;
610
+ await Promise.all([closeAllChildren(), server === null ? Promise.resolve() : withDeadline(server.close(), CHILD_CLOSE_TIMEOUT_MS, "GitHub proxy close").catch(() => void 0)]);
611
+ })();
612
+ return shutdownPromise;
613
+ }
614
+ async function main() {
615
+ const config = resolveConfig();
616
+ await loadAccounts(new AgentApiClient({
617
+ apiKey: config.apiKey,
618
+ apiUrl: config.apiUrl
619
+ }));
620
+ cachedTools = appendLocalTools(await discoverChildCatalog());
621
+ assertPatternA(cachedTools.map((tool) => ({
622
+ name: tool.name,
623
+ parameters: tool.inputSchema
206
624
  })), {
207
625
  selector: "login",
208
626
  exempt: ["github_list_accounts"]
209
627
  });
210
628
  const proxy = new Server({
211
629
  name: "github-mcp-proxy",
212
- version: "0.1.0"
630
+ version: packageMetadata.version
213
631
  }, { capabilities: { tools: {} } });
632
+ proxyServer = proxy;
214
633
  proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
215
- proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
216
- const { name, arguments: args } = request.params;
217
- const argMap = args ?? {};
218
- if (name === "github_list_accounts") return { content: [{
219
- type: "text",
220
- text: JSON.stringify({ accounts: allAccountsSnapshot }, null, 2)
221
- }] };
222
- if (name === "github_check_connection") try {
223
- const acct = resolveAccount(typeof argMap.login === "string" ? argMap.login : void 0);
224
- const response = await fetch("https://api.github.com/user", { headers: {
225
- Authorization: `Bearer ${acct.accessToken}`,
226
- Accept: "application/vnd.github+json",
227
- "X-GitHub-Api-Version": "2022-11-28",
228
- "User-Agent": "alfe-openclaw-github"
229
- } });
230
- if (!response.ok) return {
231
- content: [{
232
- type: "text",
233
- text: JSON.stringify({
234
- login: acct.login,
235
- connected: false,
236
- status: response.status,
237
- error: "Token may have been revoked. Ask the user to reconnect this GitHub account from the dashboard."
238
- })
239
- }],
240
- isError: true
241
- };
242
- const user = await response.json();
243
- return { content: [{
244
- type: "text",
245
- text: JSON.stringify({
246
- login: acct.login,
247
- connected: true,
248
- user
249
- })
250
- }] };
251
- } catch (err) {
252
- return {
253
- content: [{
254
- type: "text",
255
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
256
- }],
257
- isError: true
258
- };
259
- }
260
- let acct;
261
- try {
262
- acct = resolveAccount(typeof argMap.login === "string" ? argMap.login : void 0);
263
- } catch (err) {
264
- return {
265
- content: [{
266
- type: "text",
267
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
268
- }],
269
- isError: true
270
- };
271
- }
272
- const { login: _ignored, ...forwarded } = argMap;
273
- try {
274
- return await acct.client.callTool({
275
- name,
276
- arguments: forwarded
277
- });
278
- } catch (err) {
279
- return {
280
- content: [{
281
- type: "text",
282
- text: JSON.stringify({
283
- login: acct.login,
284
- tool: name,
285
- error: err instanceof Error ? err.message : String(err)
286
- })
287
- }],
288
- isError: true
289
- };
290
- }
291
- });
292
- const transport = new StdioServerTransport();
293
- await proxy.connect(transport);
294
- log(`Proxy running with ${String(accounts.size)} connected account(s) and Pattern A selector enforcement`);
634
+ proxy.setRequestHandler(CallToolRequestSchema, handleToolCall);
635
+ await proxy.connect(new StdioServerTransport());
636
+ log(`Proxy running with ${String(accounts.size)} live account(s) and ${String(catalogToolNames.size)} child tool(s)`);
295
637
  }
296
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
297
- killAllChildren().then(() => {
298
- process.exit(0);
299
- });
638
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => {
639
+ shutdown().finally(() => process.exit(0));
300
640
  });
301
- main().catch((err) => {
302
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
303
- killAllChildren().finally(() => {
304
- process.exit(1);
305
- });
641
+ main().catch(async (error) => {
642
+ log(`Fatal: ${safeErrorMessage(error)}`);
643
+ await shutdown();
644
+ process.exit(1);
306
645
  });
307
646
  //#endregion
308
647
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/github-mcp",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "GitHub MCP proxy server — bridges the official @modelcontextprotocol/server-github with Alfe OAuth credentials (Pattern A multi-account)",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -19,9 +19,9 @@
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": ">=1.24.0",
21
21
  "@modelcontextprotocol/server-github": "2025.4.8",
22
- "@alfe.ai/config": "0.4.0",
23
- "@alfe.ai/agent-api-client": "0.14.0",
24
- "@alfe.ai/mcp-bundler": "0.4.0"
22
+ "@alfe.ai/config": "0.4.1",
23
+ "@alfe.ai/agent-api-client": "0.16.0",
24
+ "@alfe.ai/mcp-bundler": "0.4.1"
25
25
  },
26
26
  "license": "UNLICENSED",
27
27
  "homepage": "https://alfe.ai",
@@ -37,6 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "tsdown",
39
39
  "dev": "tsdown --watch",
40
+ "test": "vitest run",
40
41
  "typecheck": "tsc --noEmit",
41
42
  "lint": "eslint ."
42
43
  }