@alfe.ai/github-mcp 0.3.18 → 0.3.19
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/README.md +16 -0
- package/dist/server.js +584 -245
- 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
|
-
*
|
|
15
|
-
* `@modelcontextprotocol/server-github` child
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
383
|
+
const catalogToolNames = /* @__PURE__ */ new Set();
|
|
52
384
|
let cachedTools = [];
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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",
|
|
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:
|
|
420
|
+
version: packageMetadata.version
|
|
86
421
|
});
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
log(`
|
|
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:
|
|
136
|
-
displayName:
|
|
137
|
-
connectedAt:
|
|
496
|
+
login: null,
|
|
497
|
+
displayName: null,
|
|
498
|
+
connectedAt: null,
|
|
138
499
|
connected: false,
|
|
139
|
-
reason: "
|
|
500
|
+
reason: "invalid_account"
|
|
140
501
|
});
|
|
141
502
|
continue;
|
|
142
503
|
}
|
|
143
|
-
if (
|
|
144
|
-
log(`
|
|
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(
|
|
149
|
-
accounts.set(
|
|
150
|
-
|
|
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:
|
|
157
|
-
displayName:
|
|
158
|
-
connectedAt:
|
|
523
|
+
login: account.login,
|
|
524
|
+
displayName: account.displayName,
|
|
525
|
+
connectedAt: account.connectedAt,
|
|
159
526
|
connected: true
|
|
160
527
|
});
|
|
161
|
-
log(`Spawned child
|
|
162
|
-
} catch (
|
|
163
|
-
|
|
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:
|
|
167
|
-
displayName:
|
|
168
|
-
connectedAt:
|
|
532
|
+
login: account.login,
|
|
533
|
+
displayName: account.displayName,
|
|
534
|
+
connectedAt: account.connectedAt,
|
|
169
535
|
connected: false,
|
|
170
|
-
reason:
|
|
536
|
+
reason: "spawn_failed"
|
|
171
537
|
});
|
|
172
538
|
}
|
|
173
539
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
630
|
+
version: packageMetadata.version
|
|
213
631
|
}, { capabilities: { tools: {} } });
|
|
632
|
+
proxyServer = proxy;
|
|
214
633
|
proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
|
|
215
|
-
proxy.setRequestHandler(CallToolRequestSchema,
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
297
|
-
|
|
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((
|
|
302
|
-
log(`Fatal: ${
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.19",
|
|
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.
|
|
23
|
-
"@alfe.ai/agent-api-client": "0.
|
|
24
|
-
"@alfe.ai/mcp-bundler": "0.4.
|
|
22
|
+
"@alfe.ai/config": "0.4.1",
|
|
23
|
+
"@alfe.ai/agent-api-client": "0.15.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
|
}
|