claude-code-rust 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -661
- package/README.md +10 -13
- package/agent-sdk/README.md +17 -0
- package/agent-sdk/dist/bridge/commands.js +183 -19
- package/agent-sdk/dist/bridge/events.js +14 -1
- package/agent-sdk/dist/bridge/history.js +4 -1
- package/agent-sdk/dist/bridge/mcp.js +289 -0
- package/agent-sdk/dist/bridge/message_handlers.js +14 -3
- package/agent-sdk/dist/bridge/session_lifecycle.js +116 -43
- package/agent-sdk/dist/bridge/tool_calls.js +12 -3
- package/agent-sdk/dist/bridge/tooling.js +272 -7
- package/agent-sdk/dist/bridge/user_interaction.js +76 -28
- package/agent-sdk/dist/bridge.js +158 -7
- package/agent-sdk/dist/bridge.test.js +809 -28
- package/bin/claude-rs.js +1 -1
- package/package.json +8 -7
- package/scripts/postinstall.js +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A native Rust terminal interface for Claude Code. Drop-in replacement for Anthro
|
|
|
5
5
|
[](https://www.npmjs.com/package/claude-code-rust)
|
|
6
6
|
[](https://www.npmjs.com/package/claude-code-rust)
|
|
7
7
|
[](https://github.com/srothgan/claude-code-rust/actions/workflows/ci.yml)
|
|
8
|
-
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
9
9
|
[](https://nodejs.org/)
|
|
10
10
|
|
|
11
11
|
## About
|
|
@@ -19,14 +19,17 @@ Claude Code Rust replaces the stock Claude Code terminal interface with a native
|
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
|
-
###
|
|
22
|
+
### npm (global, recommended)
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
|
|
25
|
+
npm install -g claude-code-rust
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
The published package installs a `claude-rs` command and
|
|
29
|
-
prebuilt release binary for your platform during
|
|
28
|
+
The published package installs a `claude-rs` command and fetches the matching
|
|
29
|
+
prebuilt release binary for your platform during install.
|
|
30
|
+
|
|
31
|
+
If `claude-rs` resolves to an older global shim, ensure your npm global bin
|
|
32
|
+
directory comes first on `PATH` or remove the stale shim before retrying.
|
|
30
33
|
|
|
31
34
|
## Usage
|
|
32
35
|
|
|
@@ -56,20 +59,14 @@ Three-layer design:
|
|
|
56
59
|
|
|
57
60
|
**Agent Runtime** (Anthropic Agent SDK) - The TypeScript bridge drives `@anthropic-ai/claude-agent-sdk`, which manages authentication, session/query lifecycle, and tool execution.
|
|
58
61
|
|
|
59
|
-
## Known Limitations
|
|
60
|
-
|
|
61
|
-
- The config view includes the Settings tab but the Status, Usage, and MCP tabs are not yet implemented.
|
|
62
|
-
|
|
63
62
|
## Status
|
|
64
63
|
|
|
65
64
|
This project is pre-1.0 and under active development. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved.
|
|
66
65
|
|
|
67
66
|
## License
|
|
68
67
|
|
|
69
|
-
This project is licensed under the [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
By using this software, you agree to the terms of the AGPL-3.0. If you modify this software and make it available over a network, you must offer the source code to users of that service.
|
|
68
|
+
This project is licensed under the [Apache License 2.0](LICENSE).
|
|
69
|
+
Apache-2.0 was chosen to keep usage and redistribution straightforward for individual users, downstream packagers, and commercial adopters.
|
|
73
70
|
|
|
74
71
|
## Disclaimer
|
|
75
72
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# claude-rs agent-sdk bridge
|
|
2
|
+
|
|
3
|
+
Initial scaffold for the NDJSON stdio bridge that will connect Rust (`claude-code-rust`) with `@anthropic-ai/claude-agent-sdk`.
|
|
4
|
+
|
|
5
|
+
## Local build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Build output is written to `dist/bridge.mjs`.
|
|
13
|
+
|
|
14
|
+
## License
|
|
15
|
+
|
|
16
|
+
This bridge is part of the `claude-code-rust` project and is licensed under
|
|
17
|
+
the Apache License 2.0. See the repository root [LICENSE](../LICENSE).
|
|
@@ -48,38 +48,43 @@ function optionalLaunchSettings(record, key, context) {
|
|
|
48
48
|
return {};
|
|
49
49
|
}
|
|
50
50
|
const parsed = asRecord(value, `${context}.${key}`);
|
|
51
|
-
const model = optionalString(parsed, "model", `${context}.${key}`);
|
|
52
51
|
const language = optionalString(parsed, "language", `${context}.${key}`);
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const effortLevel = optionalEffortLevel(parsed, "effort_level", `${context}.${key}`);
|
|
52
|
+
const settings = optionalJsonObject(parsed, "settings", `${context}.${key}`);
|
|
53
|
+
const agentProgressSummaries = optionalBoolean(parsed, "agent_progress_summaries", `${context}.${key}`);
|
|
56
54
|
return {
|
|
57
|
-
...(model ? { model } : {}),
|
|
58
55
|
...(language ? { language } : {}),
|
|
59
|
-
...(
|
|
60
|
-
...(
|
|
61
|
-
|
|
56
|
+
...(settings ? { settings } : {}),
|
|
57
|
+
...(agentProgressSummaries !== undefined
|
|
58
|
+
? { agent_progress_summaries: agentProgressSummaries }
|
|
59
|
+
: {}),
|
|
62
60
|
};
|
|
63
61
|
}
|
|
64
|
-
function
|
|
65
|
-
const value =
|
|
66
|
-
if (value === undefined) {
|
|
62
|
+
function optionalBoolean(record, key, context) {
|
|
63
|
+
const value = record[key];
|
|
64
|
+
if (value === undefined || value === null) {
|
|
67
65
|
return undefined;
|
|
68
66
|
}
|
|
69
|
-
if (
|
|
70
|
-
|
|
67
|
+
if (typeof value !== "boolean") {
|
|
68
|
+
throw new Error(`${context}.${key} must be a boolean when provided`);
|
|
71
69
|
}
|
|
72
|
-
|
|
70
|
+
return value;
|
|
73
71
|
}
|
|
74
|
-
function
|
|
75
|
-
const value =
|
|
76
|
-
if (value === undefined) {
|
|
72
|
+
function optionalJsonObject(record, key, context) {
|
|
73
|
+
const value = record[key];
|
|
74
|
+
if (value === undefined || value === null) {
|
|
77
75
|
return undefined;
|
|
78
76
|
}
|
|
79
|
-
if (value
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
78
|
+
throw new Error(`${context}.${key} must be an object when provided`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function expectElicitationAction(record, key, context) {
|
|
83
|
+
const value = expectString(record, key, context);
|
|
84
|
+
if (value === "accept" || value === "decline" || value === "cancel") {
|
|
80
85
|
return value;
|
|
81
86
|
}
|
|
82
|
-
throw new Error(`${context}.${key} must be
|
|
87
|
+
throw new Error(`${context}.${key} must be one of accept, decline, cancel`);
|
|
83
88
|
}
|
|
84
89
|
function parsePromptChunks(record, context) {
|
|
85
90
|
const rawChunks = record.chunks;
|
|
@@ -92,6 +97,41 @@ function parsePromptChunks(record, context) {
|
|
|
92
97
|
return { kind, value: (parsed.value ?? null) };
|
|
93
98
|
});
|
|
94
99
|
}
|
|
100
|
+
function expectBoolean(record, key, context) {
|
|
101
|
+
const value = record[key];
|
|
102
|
+
if (typeof value !== "boolean") {
|
|
103
|
+
throw new Error(`${context}.${key} must be a boolean`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function parseMcpServerConfig(value, context) {
|
|
108
|
+
const record = asRecord(value, context);
|
|
109
|
+
const type = expectString(record, "type", context);
|
|
110
|
+
switch (type) {
|
|
111
|
+
case "stdio":
|
|
112
|
+
return {
|
|
113
|
+
type,
|
|
114
|
+
command: expectString(record, "command", context),
|
|
115
|
+
...(record.args === undefined ? {} : { args: expectStringArray(record, "args", context) }),
|
|
116
|
+
...(record.env === undefined ? {} : { env: expectStringMap(record, "env", context) }),
|
|
117
|
+
};
|
|
118
|
+
case "sse":
|
|
119
|
+
case "http":
|
|
120
|
+
return {
|
|
121
|
+
type,
|
|
122
|
+
url: expectString(record, "url", context),
|
|
123
|
+
...(record.headers === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { headers: expectStringMap(record, "headers", context) }),
|
|
126
|
+
};
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`${context}.type must be one of stdio, sse, http`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function parseMcpServersRecord(value, context) {
|
|
132
|
+
const record = asRecord(value, context);
|
|
133
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, parseMcpServerConfig(entry, `${context}.${key}`)]));
|
|
134
|
+
}
|
|
95
135
|
export function parseCommandEnvelope(line) {
|
|
96
136
|
const raw = asRecord(JSON.parse(line), "command envelope");
|
|
97
137
|
const requestId = typeof raw.request_id === "string" ? raw.request_id : undefined;
|
|
@@ -148,6 +188,48 @@ export function parseCommandEnvelope(line) {
|
|
|
148
188
|
session_id: expectString(raw, "session_id", "set_mode"),
|
|
149
189
|
mode: expectString(raw, "mode", "set_mode"),
|
|
150
190
|
};
|
|
191
|
+
case "generate_session_title":
|
|
192
|
+
return {
|
|
193
|
+
command: "generate_session_title",
|
|
194
|
+
session_id: expectString(raw, "session_id", "generate_session_title"),
|
|
195
|
+
description: expectString(raw, "description", "generate_session_title"),
|
|
196
|
+
};
|
|
197
|
+
case "rename_session":
|
|
198
|
+
return {
|
|
199
|
+
command: "rename_session",
|
|
200
|
+
session_id: expectString(raw, "session_id", "rename_session"),
|
|
201
|
+
title: expectString(raw, "title", "rename_session"),
|
|
202
|
+
};
|
|
203
|
+
case "get_status_snapshot":
|
|
204
|
+
return {
|
|
205
|
+
command: "get_status_snapshot",
|
|
206
|
+
session_id: expectString(raw, "session_id", "get_status_snapshot"),
|
|
207
|
+
};
|
|
208
|
+
case "mcp_status":
|
|
209
|
+
case "get_mcp_snapshot":
|
|
210
|
+
return {
|
|
211
|
+
command: "mcp_status",
|
|
212
|
+
session_id: expectString(raw, "session_id", commandName),
|
|
213
|
+
};
|
|
214
|
+
case "mcp_reconnect":
|
|
215
|
+
return {
|
|
216
|
+
command: "mcp_reconnect",
|
|
217
|
+
session_id: expectString(raw, "session_id", "mcp_reconnect"),
|
|
218
|
+
server_name: expectString(raw, "server_name", "mcp_reconnect"),
|
|
219
|
+
};
|
|
220
|
+
case "mcp_toggle":
|
|
221
|
+
return {
|
|
222
|
+
command: "mcp_toggle",
|
|
223
|
+
session_id: expectString(raw, "session_id", "mcp_toggle"),
|
|
224
|
+
server_name: expectString(raw, "server_name", "mcp_toggle"),
|
|
225
|
+
enabled: expectBoolean(raw, "enabled", "mcp_toggle"),
|
|
226
|
+
};
|
|
227
|
+
case "mcp_set_servers":
|
|
228
|
+
return {
|
|
229
|
+
command: "mcp_set_servers",
|
|
230
|
+
session_id: expectString(raw, "session_id", "mcp_set_servers"),
|
|
231
|
+
servers: parseMcpServersRecord(raw.servers ?? {}, "mcp_set_servers.servers"),
|
|
232
|
+
};
|
|
151
233
|
case "permission_response": {
|
|
152
234
|
const outcome = asRecord(raw.outcome, "permission_response.outcome");
|
|
153
235
|
const outcomeType = expectString(outcome, "outcome", "permission_response.outcome");
|
|
@@ -167,6 +249,57 @@ export function parseCommandEnvelope(line) {
|
|
|
167
249
|
outcome: parsedOutcome,
|
|
168
250
|
};
|
|
169
251
|
}
|
|
252
|
+
case "question_response": {
|
|
253
|
+
const outcome = asRecord(raw.outcome, "question_response.outcome");
|
|
254
|
+
const outcomeType = expectString(outcome, "outcome", "question_response.outcome");
|
|
255
|
+
if (outcomeType !== "answered" && outcomeType !== "cancelled") {
|
|
256
|
+
throw new Error("question_response.outcome.outcome must be 'answered' or 'cancelled'");
|
|
257
|
+
}
|
|
258
|
+
const parsedOutcome = outcomeType === "answered"
|
|
259
|
+
? {
|
|
260
|
+
outcome: "answered",
|
|
261
|
+
selected_option_ids: expectStringArray(outcome, "selected_option_ids", "question_response.outcome"),
|
|
262
|
+
...(outcome.annotation === undefined || outcome.annotation === null
|
|
263
|
+
? {}
|
|
264
|
+
: { annotation: parseQuestionAnnotation(outcome.annotation) }),
|
|
265
|
+
}
|
|
266
|
+
: { outcome: "cancelled" };
|
|
267
|
+
return {
|
|
268
|
+
command: "question_response",
|
|
269
|
+
session_id: expectString(raw, "session_id", "question_response"),
|
|
270
|
+
tool_call_id: expectString(raw, "tool_call_id", "question_response"),
|
|
271
|
+
outcome: parsedOutcome,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "elicitation_response":
|
|
275
|
+
return {
|
|
276
|
+
command: "elicitation_response",
|
|
277
|
+
session_id: expectString(raw, "session_id", "elicitation_response"),
|
|
278
|
+
elicitation_request_id: expectString(raw, "elicitation_request_id", "elicitation_response"),
|
|
279
|
+
action: expectElicitationAction(raw, "action", "elicitation_response"),
|
|
280
|
+
...(optionalJsonObject(raw, "content", "elicitation_response")
|
|
281
|
+
? { content: optionalJsonObject(raw, "content", "elicitation_response") }
|
|
282
|
+
: {}),
|
|
283
|
+
};
|
|
284
|
+
case "mcp_authenticate":
|
|
285
|
+
return {
|
|
286
|
+
command: "mcp_authenticate",
|
|
287
|
+
session_id: expectString(raw, "session_id", "mcp_authenticate"),
|
|
288
|
+
server_name: expectString(raw, "server_name", "mcp_authenticate"),
|
|
289
|
+
};
|
|
290
|
+
case "mcp_clear_auth":
|
|
291
|
+
return {
|
|
292
|
+
command: "mcp_clear_auth",
|
|
293
|
+
session_id: expectString(raw, "session_id", "mcp_clear_auth"),
|
|
294
|
+
server_name: expectString(raw, "server_name", "mcp_clear_auth"),
|
|
295
|
+
};
|
|
296
|
+
case "mcp_oauth_callback_url":
|
|
297
|
+
return {
|
|
298
|
+
command: "mcp_oauth_callback_url",
|
|
299
|
+
session_id: expectString(raw, "session_id", "mcp_oauth_callback_url"),
|
|
300
|
+
server_name: expectString(raw, "server_name", "mcp_oauth_callback_url"),
|
|
301
|
+
callback_url: expectString(raw, "callback_url", "mcp_oauth_callback_url"),
|
|
302
|
+
};
|
|
170
303
|
case "shutdown":
|
|
171
304
|
return { command: "shutdown" };
|
|
172
305
|
default:
|
|
@@ -175,6 +308,37 @@ export function parseCommandEnvelope(line) {
|
|
|
175
308
|
})();
|
|
176
309
|
return { requestId, command };
|
|
177
310
|
}
|
|
311
|
+
function expectStringArray(record, key, context) {
|
|
312
|
+
const value = record[key];
|
|
313
|
+
if (!Array.isArray(value)) {
|
|
314
|
+
throw new Error(`${context}.${key} must be an array`);
|
|
315
|
+
}
|
|
316
|
+
return value.map((entry, index) => {
|
|
317
|
+
if (typeof entry !== "string") {
|
|
318
|
+
throw new Error(`${context}.${key}[${index}] must be a string`);
|
|
319
|
+
}
|
|
320
|
+
return entry;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function expectStringMap(record, key, context) {
|
|
324
|
+
const value = record[key];
|
|
325
|
+
const parsed = asRecord(value, `${context}.${key}`);
|
|
326
|
+
return Object.fromEntries(Object.entries(parsed).map(([entryKey, entryValue]) => {
|
|
327
|
+
if (typeof entryValue !== "string") {
|
|
328
|
+
throw new Error(`${context}.${key}.${entryKey} must be a string`);
|
|
329
|
+
}
|
|
330
|
+
return [entryKey, entryValue];
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
function parseQuestionAnnotation(value) {
|
|
334
|
+
const record = asRecord(value, "question_response.outcome.annotation");
|
|
335
|
+
const preview = optionalString(record, "preview", "question_response.outcome.annotation");
|
|
336
|
+
const notes = optionalString(record, "notes", "question_response.outcome.annotation");
|
|
337
|
+
return {
|
|
338
|
+
...(preview !== undefined ? { preview } : {}),
|
|
339
|
+
...(notes !== undefined ? { notes } : {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
178
342
|
export function toPermissionMode(mode) {
|
|
179
343
|
if (mode === "default" ||
|
|
180
344
|
mode === "acceptEdits" ||
|
|
@@ -2,6 +2,16 @@ import { listSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
|
2
2
|
import { buildModeState } from "./commands.js";
|
|
3
3
|
import { mapSdkSessions } from "./history.js";
|
|
4
4
|
const SESSION_LIST_LIMIT = 50;
|
|
5
|
+
let sessionListingDir;
|
|
6
|
+
export function buildSessionListOptions(dir, limit = SESSION_LIST_LIMIT) {
|
|
7
|
+
return dir ? { dir, includeWorktrees: true, limit } : { limit };
|
|
8
|
+
}
|
|
9
|
+
export function setSessionListingDir(dir) {
|
|
10
|
+
sessionListingDir = dir;
|
|
11
|
+
}
|
|
12
|
+
export function currentSessionListOptions() {
|
|
13
|
+
return buildSessionListOptions(sessionListingDir);
|
|
14
|
+
}
|
|
5
15
|
export function writeEvent(event, requestId) {
|
|
6
16
|
const envelope = {
|
|
7
17
|
...(requestId ? { request_id: requestId } : {}),
|
|
@@ -15,6 +25,9 @@ export function failConnection(message, requestId) {
|
|
|
15
25
|
export function slashError(sessionId, message, requestId) {
|
|
16
26
|
writeEvent({ event: "slash_error", session_id: sessionId, message }, requestId);
|
|
17
27
|
}
|
|
28
|
+
export function emitMcpOperationError(sessionId, error, requestId) {
|
|
29
|
+
writeEvent({ event: "mcp_operation_error", session_id: sessionId, error }, requestId);
|
|
30
|
+
}
|
|
18
31
|
export function emitSessionUpdate(sessionId, update) {
|
|
19
32
|
writeEvent({ event: "session_update", session_id: sessionId, update });
|
|
20
33
|
}
|
|
@@ -67,7 +80,7 @@ export function emitConnectEvent(session) {
|
|
|
67
80
|
}
|
|
68
81
|
export async function emitSessionsList(requestId) {
|
|
69
82
|
try {
|
|
70
|
-
const sdkSessions = await listSessions(
|
|
83
|
+
const sdkSessions = await listSessions(currentSessionListOptions());
|
|
71
84
|
writeEvent({ event: "sessions_listed", sessions: mapSdkSessions(sdkSessions, SESSION_LIST_LIMIT) }, requestId);
|
|
72
85
|
}
|
|
73
86
|
catch (error) {
|
|
@@ -48,7 +48,7 @@ function pushResumeToolResult(updates, toolCalls, block) {
|
|
|
48
48
|
}
|
|
49
49
|
const isError = Boolean(block.is_error);
|
|
50
50
|
const base = toolCalls.get(toolUseId);
|
|
51
|
-
const fields = buildToolResultFields(isError, block.content, base);
|
|
51
|
+
const fields = buildToolResultFields(isError, block.content, base, block);
|
|
52
52
|
updates.push({ type: "tool_call_update", tool_call_update: { tool_call_id: toolUseId, fields } });
|
|
53
53
|
if (!base) {
|
|
54
54
|
return;
|
|
@@ -60,6 +60,9 @@ function pushResumeToolResult(updates, toolCalls, block) {
|
|
|
60
60
|
if (fields.content) {
|
|
61
61
|
base.content = fields.content;
|
|
62
62
|
}
|
|
63
|
+
if (fields.output_metadata) {
|
|
64
|
+
base.output_metadata = fields.output_metadata;
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
function summaryFromSession(info) {
|
|
65
68
|
return (nonEmptyTrimmed(info.summary) ??
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { emitMcpOperationError, slashError, writeEvent } from "./events.js";
|
|
2
|
+
export const MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS = 30_000;
|
|
3
|
+
const knownConnectedMcpServers = new Set();
|
|
4
|
+
function queryWithMcpAuth(session) {
|
|
5
|
+
return session.query;
|
|
6
|
+
}
|
|
7
|
+
async function callMcpAuthMethod(session, methodName, args) {
|
|
8
|
+
const query = queryWithMcpAuth(session);
|
|
9
|
+
switch (methodName) {
|
|
10
|
+
case "mcpAuthenticate":
|
|
11
|
+
if (typeof query.mcpAuthenticate !== "function") {
|
|
12
|
+
throw new Error("installed SDK does not support mcpAuthenticate");
|
|
13
|
+
}
|
|
14
|
+
return await query.mcpAuthenticate(args[0] ?? "");
|
|
15
|
+
case "mcpClearAuth":
|
|
16
|
+
if (typeof query.mcpClearAuth !== "function") {
|
|
17
|
+
throw new Error("installed SDK does not support mcpClearAuth");
|
|
18
|
+
}
|
|
19
|
+
return await query.mcpClearAuth(args[0] ?? "");
|
|
20
|
+
case "mcpSubmitOAuthCallbackUrl":
|
|
21
|
+
if (typeof query.mcpSubmitOAuthCallbackUrl !== "function") {
|
|
22
|
+
throw new Error("installed SDK does not support mcpSubmitOAuthCallbackUrl");
|
|
23
|
+
}
|
|
24
|
+
return await query.mcpSubmitOAuthCallbackUrl(args[0] ?? "", args[1] ?? "");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function extractMcpAuthRedirect(serverName, value) {
|
|
28
|
+
if (!value || typeof value !== "object") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const authUrl = Reflect.get(value, "authUrl");
|
|
32
|
+
if (typeof authUrl !== "string" || authUrl.trim().length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const requiresUserAction = Reflect.get(value, "requiresUserAction");
|
|
36
|
+
return {
|
|
37
|
+
server_name: serverName,
|
|
38
|
+
auth_url: authUrl,
|
|
39
|
+
requires_user_action: requiresUserAction === true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function emitMcpCommandError(sessionId, operation, message, requestId, serverName) {
|
|
43
|
+
emitMcpOperationError(sessionId, {
|
|
44
|
+
...(serverName ? { server_name: serverName } : {}),
|
|
45
|
+
operation,
|
|
46
|
+
message,
|
|
47
|
+
}, requestId);
|
|
48
|
+
}
|
|
49
|
+
export async function emitMcpSnapshotEvent(session, requestId) {
|
|
50
|
+
const servers = await session.query.mcpServerStatus();
|
|
51
|
+
let mapped = servers.map(mapMcpServerStatus);
|
|
52
|
+
mapped = await reconcileSuspiciousMcpStatuses(session, mapped);
|
|
53
|
+
rememberKnownConnectedMcpServers(mapped);
|
|
54
|
+
writeEvent({
|
|
55
|
+
event: "mcp_snapshot",
|
|
56
|
+
session_id: session.sessionId,
|
|
57
|
+
servers: mapped,
|
|
58
|
+
}, requestId);
|
|
59
|
+
return mapped;
|
|
60
|
+
}
|
|
61
|
+
export function staleMcpAuthCandidates(servers, knownConnectedServerNames, lastRevalidatedAt, now = Date.now(), cooldownMs = MCP_STALE_STATUS_REVALIDATION_COOLDOWN_MS) {
|
|
62
|
+
return servers
|
|
63
|
+
.filter((server) => {
|
|
64
|
+
if (server.status !== "needs-auth") {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!knownConnectedServerNames.has(server.name)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const lastAttempt = lastRevalidatedAt.get(server.name) ?? 0;
|
|
71
|
+
return now - lastAttempt >= cooldownMs;
|
|
72
|
+
})
|
|
73
|
+
.map((server) => server.name);
|
|
74
|
+
}
|
|
75
|
+
function rememberKnownConnectedMcpServers(servers) {
|
|
76
|
+
for (const server of servers) {
|
|
77
|
+
if (server.status === "connected") {
|
|
78
|
+
knownConnectedMcpServers.add(server.name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function forgetKnownConnectedMcpServer(serverName) {
|
|
83
|
+
knownConnectedMcpServers.delete(serverName);
|
|
84
|
+
}
|
|
85
|
+
async function reconcileSuspiciousMcpStatuses(session, servers) {
|
|
86
|
+
const candidates = staleMcpAuthCandidates(servers, knownConnectedMcpServers, session.mcpStatusRevalidatedAt);
|
|
87
|
+
if (candidates.length === 0) {
|
|
88
|
+
return servers;
|
|
89
|
+
}
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
for (const serverName of candidates) {
|
|
92
|
+
session.mcpStatusRevalidatedAt.set(serverName, now);
|
|
93
|
+
console.error(`[sdk mcp reconcile] session=${session.sessionId} server=${serverName} ` +
|
|
94
|
+
`status=needs-auth reason=previously-connected action=reconnect`);
|
|
95
|
+
try {
|
|
96
|
+
await session.query.reconnectMcpServer(serverName);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
console.error(`[sdk mcp reconcile] session=${session.sessionId} server=${serverName} ` +
|
|
101
|
+
`action=reconnect failed=${message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return (await session.query.mcpServerStatus()).map(mapMcpServerStatus);
|
|
105
|
+
}
|
|
106
|
+
function shouldKeepMonitoringMcpAuth(server) {
|
|
107
|
+
return server?.status === "needs-auth" || server?.status === "pending";
|
|
108
|
+
}
|
|
109
|
+
function scheduleMcpAuthSnapshotMonitor(session, serverName, attempt = 0) {
|
|
110
|
+
const maxAttempts = 180;
|
|
111
|
+
const delayMs = 1000;
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs);
|
|
114
|
+
}, delayMs);
|
|
115
|
+
}
|
|
116
|
+
async function monitorMcpAuthSnapshot(session, serverName, attempt, maxAttempts, delayMs) {
|
|
117
|
+
try {
|
|
118
|
+
const servers = await emitMcpSnapshotEvent(session);
|
|
119
|
+
const server = servers.find((candidate) => candidate.name === serverName);
|
|
120
|
+
if (attempt < maxAttempts && shouldKeepMonitoringMcpAuth(server)) {
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs);
|
|
123
|
+
}, delayMs);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
if (attempt < maxAttempts) {
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
void monitorMcpAuthSnapshot(session, serverName, attempt + 1, maxAttempts, delayMs);
|
|
130
|
+
}, delayMs);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function handleMcpStatusCommand(session, requestId) {
|
|
135
|
+
try {
|
|
136
|
+
await emitMcpSnapshotEvent(session, requestId);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
|
+
writeEvent({
|
|
141
|
+
event: "mcp_snapshot",
|
|
142
|
+
session_id: session.sessionId,
|
|
143
|
+
servers: [],
|
|
144
|
+
error: message,
|
|
145
|
+
}, requestId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function handleMcpReconnectCommand(session, command, requestId) {
|
|
149
|
+
try {
|
|
150
|
+
await session.query.reconnectMcpServer(command.server_name);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
emitMcpCommandError(command.session_id, "reconnect", message, requestId, command.server_name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function handleMcpToggleCommand(session, command, requestId) {
|
|
158
|
+
try {
|
|
159
|
+
await session.query.toggleMcpServer(command.server_name, command.enabled);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
emitMcpCommandError(command.session_id, "toggle", message, requestId, command.server_name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function handleMcpSetServersCommand(session, command, requestId) {
|
|
167
|
+
try {
|
|
168
|
+
await session.query.setMcpServers(command.servers);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
slashError(command.session_id, `failed to set MCP servers: ${message}`, requestId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function handleMcpAuthenticateCommand(session, command, requestId) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await callMcpAuthMethod(session, "mcpAuthenticate", [command.server_name]);
|
|
178
|
+
const redirect = extractMcpAuthRedirect(command.server_name, result);
|
|
179
|
+
if (redirect) {
|
|
180
|
+
writeEvent({
|
|
181
|
+
event: "mcp_auth_redirect",
|
|
182
|
+
session_id: command.session_id,
|
|
183
|
+
redirect,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
scheduleMcpAuthSnapshotMonitor(session, command.server_name);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
emitMcpCommandError(command.session_id, "authenticate", message, requestId, command.server_name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function handleMcpClearAuthCommand(session, command, requestId) {
|
|
194
|
+
try {
|
|
195
|
+
await callMcpAuthMethod(session, "mcpClearAuth", [command.server_name]);
|
|
196
|
+
forgetKnownConnectedMcpServer(command.server_name);
|
|
197
|
+
session.mcpStatusRevalidatedAt.delete(command.server_name);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
emitMcpCommandError(command.session_id, "clear-auth", message, requestId, command.server_name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function handleMcpOauthCallbackUrlCommand(session, command, requestId) {
|
|
205
|
+
try {
|
|
206
|
+
await callMcpAuthMethod(session, "mcpSubmitOAuthCallbackUrl", [
|
|
207
|
+
command.server_name,
|
|
208
|
+
command.callback_url,
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
213
|
+
emitMcpCommandError(command.session_id, "submit-callback-url", message, requestId, command.server_name);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function mapMcpServerStatus(status) {
|
|
217
|
+
return {
|
|
218
|
+
name: status.name,
|
|
219
|
+
status: status.status,
|
|
220
|
+
...(status.serverInfo
|
|
221
|
+
? {
|
|
222
|
+
server_info: {
|
|
223
|
+
name: status.serverInfo.name,
|
|
224
|
+
version: status.serverInfo.version,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
: {}),
|
|
228
|
+
...(status.error ? { error: status.error } : {}),
|
|
229
|
+
...(status.config ? { config: mapMcpServerStatusConfig(status.config) } : {}),
|
|
230
|
+
...(status.scope ? { scope: status.scope } : {}),
|
|
231
|
+
tools: Array.isArray(status.tools)
|
|
232
|
+
? status.tools.map((tool) => ({
|
|
233
|
+
name: tool.name,
|
|
234
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
235
|
+
...(tool.annotations
|
|
236
|
+
? {
|
|
237
|
+
annotations: {
|
|
238
|
+
...(typeof tool.annotations.readOnly === "boolean"
|
|
239
|
+
? { read_only: tool.annotations.readOnly }
|
|
240
|
+
: {}),
|
|
241
|
+
...(typeof tool.annotations.destructive === "boolean"
|
|
242
|
+
? { destructive: tool.annotations.destructive }
|
|
243
|
+
: {}),
|
|
244
|
+
...(typeof tool.annotations.openWorld === "boolean"
|
|
245
|
+
? { open_world: tool.annotations.openWorld }
|
|
246
|
+
: {}),
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
250
|
+
}))
|
|
251
|
+
: [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function mapMcpServerStatusConfig(config) {
|
|
255
|
+
switch (config.type) {
|
|
256
|
+
case "stdio":
|
|
257
|
+
return {
|
|
258
|
+
type: "stdio",
|
|
259
|
+
command: config.command,
|
|
260
|
+
...(Array.isArray(config.args) && config.args.length > 0 ? { args: config.args } : {}),
|
|
261
|
+
...(config.env ? { env: config.env } : {}),
|
|
262
|
+
};
|
|
263
|
+
case "sse":
|
|
264
|
+
return {
|
|
265
|
+
type: "sse",
|
|
266
|
+
url: config.url,
|
|
267
|
+
...(config.headers ? { headers: config.headers } : {}),
|
|
268
|
+
};
|
|
269
|
+
case "http":
|
|
270
|
+
return {
|
|
271
|
+
type: "http",
|
|
272
|
+
url: config.url,
|
|
273
|
+
...(config.headers ? { headers: config.headers } : {}),
|
|
274
|
+
};
|
|
275
|
+
case "sdk":
|
|
276
|
+
return {
|
|
277
|
+
type: "sdk",
|
|
278
|
+
name: config.name,
|
|
279
|
+
};
|
|
280
|
+
case "claudeai-proxy":
|
|
281
|
+
return {
|
|
282
|
+
type: "claudeai-proxy",
|
|
283
|
+
url: config.url,
|
|
284
|
+
id: config.id,
|
|
285
|
+
};
|
|
286
|
+
default:
|
|
287
|
+
throw new Error(`unsupported MCP status config: ${JSON.stringify(config)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|