@oar-sh/oar 0.9.2 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -3,9 +3,12 @@ import {
|
|
|
3
3
|
startGrokTurn,
|
|
4
4
|
classifyGrokError,
|
|
5
5
|
} from './grok-sdk-adapter.mjs';
|
|
6
|
+
import { AcpClient, toAcpStdioMcpServer, withAcpMcpServers } from './acp-client.mjs';
|
|
6
7
|
import { buildGrokContextUsage, resolveGrokContextWindow } from './grok-context-usage.mjs';
|
|
7
8
|
import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
|
|
9
|
+
import { OAR_MCP_SERVER_NAME, buildOarMcpServerLaunch, isOarMcpServerDisabled } from '../mcp/oar-mcp-server.mjs';
|
|
8
10
|
import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
|
|
11
|
+
import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mjs';
|
|
9
12
|
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
10
13
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
11
14
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
@@ -110,8 +113,30 @@ export function createGrokTurnRunner({
|
|
|
110
113
|
startGrokTurnImpl = startGrokTurn,
|
|
111
114
|
classifyErrorImpl = classifyGrokError,
|
|
112
115
|
getPreviewInstructions = createPreviewInstructionsProvider({ api }),
|
|
116
|
+
env = process.env,
|
|
117
|
+
nodePath = process.execPath,
|
|
118
|
+
// The OAR MCP server launch (`{ command, args, env }`) every Grok session
|
|
119
|
+
// gets through ACP `mcpServers`; undefined builds the default for this
|
|
120
|
+
// worker's conversation, null leaves Grok without it.
|
|
121
|
+
oarMcpServer = undefined,
|
|
113
122
|
dbg = () => {},
|
|
114
123
|
} = {}) {
|
|
124
|
+
// The OAR MCP server gives Grok the relay's real tools — `preview`, and
|
|
125
|
+
// `remote_relay` when remotes are paired — bound to this conversation.
|
|
126
|
+
// `OAR_MCP_SERVER=0` is the operator's way back to the preview instruction
|
|
127
|
+
// block, should an agent build turn out to ignore ACP `mcpServers`.
|
|
128
|
+
const oarMcpLaunch = oarMcpServer !== undefined
|
|
129
|
+
? (oarMcpServer || null)
|
|
130
|
+
: (() => {
|
|
131
|
+
if (isOarMcpServerDisabled(env)) return null;
|
|
132
|
+
try {
|
|
133
|
+
return buildOarMcpServerLaunch({ nodePath, conversationId: sdkSessionId, env });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
dbg('oar mcp server unavailable', error?.message || String(error));
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
const acpMcpServers = oarMcpLaunch ? [toAcpStdioMcpServer(oarMcpLaunch, OAR_MCP_SERVER_NAME)] : [];
|
|
115
140
|
let agentHandle = null;
|
|
116
141
|
let grokNativeSessionId = '';
|
|
117
142
|
let activeMessage = null;
|
|
@@ -175,6 +200,12 @@ export function createGrokTurnRunner({
|
|
|
175
200
|
alwaysApprove,
|
|
176
201
|
nativeSessionId: resumeId,
|
|
177
202
|
model,
|
|
203
|
+
// The adapter builds the ACP client and opens the session in one step,
|
|
204
|
+
// so the client it builds must already carry the MCP servers — for
|
|
205
|
+
// session/new and for session/load alike.
|
|
206
|
+
...(acpMcpServers.length
|
|
207
|
+
? { AcpClientImpl: withAcpMcpServers(AcpClient, acpMcpServers), mcpServers: acpMcpServers }
|
|
208
|
+
: {}),
|
|
178
209
|
dbg,
|
|
179
210
|
});
|
|
180
211
|
if (agentHandle.sessionId) {
|
|
@@ -188,6 +219,20 @@ export function createGrokTurnRunner({
|
|
|
188
219
|
return agentHandle;
|
|
189
220
|
}
|
|
190
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The inactivity hold. A remote_relay call runs inside the MCP server, which
|
|
224
|
+
* this worker cannot see: Grok just goes quiet for as long as the relay
|
|
225
|
+
* waits on the remote turn. Before the ACP watchdog calls that a stall, the
|
|
226
|
+
* relay is asked whether a call of this conversation is still in flight.
|
|
227
|
+
* Keyed by the MCP server's own conversation id, which is what its calls
|
|
228
|
+
* carry.
|
|
229
|
+
*/
|
|
230
|
+
async function remoteRelayCallInFlight() {
|
|
231
|
+
const inflight = await fetchRemoteRelayInflight({ api, conversationId: sdkSessionId });
|
|
232
|
+
if (inflight > 0) dbg('remote_relay call in flight; holding the inactivity watchdog', String(inflight));
|
|
233
|
+
return inflight > 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
191
236
|
async function postActivity(message, text, subagentRunId = null) {
|
|
192
237
|
if (!text) return;
|
|
193
238
|
await api('POST', '/api/activity', {
|
|
@@ -369,7 +414,9 @@ export function createGrokTurnRunner({
|
|
|
369
414
|
const modeNudge = grokModeNudge(message.relayMode, lastNudgedRelayMode);
|
|
370
415
|
const userText = buildGrokUserText(message);
|
|
371
416
|
// Advisory: a preview lookup that fails costs the guidance, never the turn.
|
|
372
|
-
|
|
417
|
+
// With the OAR MCP server wired in, `preview` is a real tool and the HTTP
|
|
418
|
+
// instruction block (which says there is none) would contradict it.
|
|
419
|
+
const previewBlock = previewInstructionsSent || acpMcpServers.length
|
|
373
420
|
? ''
|
|
374
421
|
: await Promise.resolve()
|
|
375
422
|
.then(() => getPreviewInstructions?.())
|
|
@@ -378,6 +425,16 @@ export function createGrokTurnRunner({
|
|
|
378
425
|
const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
|
|
379
426
|
const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
|
|
380
427
|
|
|
428
|
+
// The user's max-turn-duration setting overrides the worker-local
|
|
429
|
+
// absolute cap: "No limit" (0) must not be silently trimmed to the
|
|
430
|
+
// 30-minute default. The inactivity watchdog stays — it detects a dead
|
|
431
|
+
// transport, not a long turn — but asks the relay before it fires while
|
|
432
|
+
// Grok may be blocked on a remote_relay call.
|
|
433
|
+
const watchdog = {
|
|
434
|
+
...(turnCeilingMs !== null ? { maxTurnMs: turnCeilingMs > 0 ? turnCeilingMs : 0 } : {}),
|
|
435
|
+
...(acpMcpServers.length ? { holdStall: remoteRelayCallInFlight } : {}),
|
|
436
|
+
};
|
|
437
|
+
|
|
381
438
|
let turn = null;
|
|
382
439
|
let planBoardPosted = false;
|
|
383
440
|
const controlState = controlPoller?.start?.({
|
|
@@ -399,13 +456,7 @@ export function createGrokTurnRunner({
|
|
|
399
456
|
text: promptText,
|
|
400
457
|
reasoningEffort: message.reasoningEffort || '',
|
|
401
458
|
abortSignal: abortController.signal,
|
|
402
|
-
|
|
403
|
-
// absolute cap: "No limit" (0) must not be silently trimmed to
|
|
404
|
-
// the 30-minute default. The inactivity watchdog stays — it
|
|
405
|
-
// detects a dead transport, not a long turn.
|
|
406
|
-
watchdog: turnCeilingMs !== null
|
|
407
|
-
? { maxTurnMs: turnCeilingMs > 0 ? turnCeilingMs : 0 }
|
|
408
|
-
: null,
|
|
459
|
+
watchdog: Object.keys(watchdog).length ? watchdog : null,
|
|
409
460
|
dbg,
|
|
410
461
|
});
|
|
411
462
|
for await (const action of turn) {
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
6
6
|
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
7
|
+
import {
|
|
8
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
9
|
+
isRemoteRelayToolName,
|
|
10
|
+
remoteRelayActivitySummary,
|
|
11
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
7
12
|
|
|
8
13
|
const MAX_TOOL_DETAIL_LENGTH = 140;
|
|
9
14
|
const SUBAGENT_TOOL_NAMES = new Set(['task', 'agent', 'subagent']);
|
|
@@ -59,6 +64,14 @@ export function summarizeToolInput(input) {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export function formatToolActivityText(toolName, input, title = '') {
|
|
67
|
+
// The OAR MCP server's `remote_relay`, however the agent labels it (bare,
|
|
68
|
+
// or namespaced by the server), reads as what it does on which relay.
|
|
69
|
+
if (isRemoteRelayToolName(toolName) || isRemoteRelayToolName(title)) {
|
|
70
|
+
const remoteSummary = remoteRelayActivitySummary(input);
|
|
71
|
+
return truncate(remoteSummary
|
|
72
|
+
? `Tool (${REMOTE_RELAY_TOOL_NAME}): ${remoteSummary}`
|
|
73
|
+
: `Tool (${REMOTE_RELAY_TOOL_NAME})`);
|
|
74
|
+
}
|
|
62
75
|
const name = String(toolName || title || 'tool').trim() || 'tool';
|
|
63
76
|
const summary = summarizeToolInput(input) || String(title || '').trim();
|
|
64
77
|
return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// OAR's own stdio MCP server. It gives the agents OAR launches without an
|
|
3
|
+
// in-process tool surface — Grok (ACP `mcpServers`) and the Copilot CLI
|
|
4
|
+
// extension engine (`--additional-mcp-config`) — the relay's real tools:
|
|
5
|
+
// `remote_relay` and `preview`, bound to the one conversation it was started
|
|
6
|
+
// for, so the relay's mention gate and approval apply exactly as they do for
|
|
7
|
+
// the Claude, Cursor and Copilot SDK workers.
|
|
8
|
+
//
|
|
9
|
+
// Hand-written on purpose (no MCP SDK dependency): JSON-RPC 2.0 as
|
|
10
|
+
// newline-delimited JSON on stdin/stdout. stdout carries protocol frames ONLY;
|
|
11
|
+
// every diagnostic goes to stderr.
|
|
12
|
+
//
|
|
13
|
+
// node server/mcp/oar-mcp-server.mjs --conversation-id <id>
|
|
14
|
+
//
|
|
15
|
+
// The relay is found the way the session workers find it: the config at
|
|
16
|
+
// COPILOT_WEB_RELAY_CONFIG (else server/config.json) supplies the token and
|
|
17
|
+
// port, COPILOT_WEB_RELAY_SERVER_URL overrides the address.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import readline from 'node:readline';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
25
|
+
import { loadTokenFromConfig, resolveRelayServerUrl } from '../../shared/worker-runtime/config-loader.mjs';
|
|
26
|
+
import {
|
|
27
|
+
PREVIEW_TOOL_DESCRIPTION,
|
|
28
|
+
PREVIEW_TOOL_INPUT_SCHEMA,
|
|
29
|
+
PREVIEW_TOOL_NAME,
|
|
30
|
+
executePreviewTool,
|
|
31
|
+
} from '../../shared/preview-tool-core.mjs';
|
|
32
|
+
import {
|
|
33
|
+
REMOTE_RELAY_CALL_FAILED,
|
|
34
|
+
REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS,
|
|
35
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
36
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
37
|
+
cloneRemoteRelayToolInputSchema,
|
|
38
|
+
executeRemoteRelayTool,
|
|
39
|
+
formatRemoteRelayToolResult,
|
|
40
|
+
shouldRegisterRemoteRelayTool,
|
|
41
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
42
|
+
|
|
43
|
+
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
44
|
+
|
|
45
|
+
/** The server name hosts register it under (`oar`), and its `serverInfo.name`. */
|
|
46
|
+
export const OAR_MCP_SERVER_NAME = 'oar';
|
|
47
|
+
export const OAR_MCP_SERVER_SCRIPT_PATH = MODULE_PATH;
|
|
48
|
+
|
|
49
|
+
export const OAR_MCP_DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
50
|
+
// Everything this server speaks is the tools subset every revision shares, so
|
|
51
|
+
// a client's requested revision is echoed back when it is one of these.
|
|
52
|
+
export const OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
|
|
53
|
+
'2025-11-25',
|
|
54
|
+
'2025-06-18',
|
|
55
|
+
'2025-03-26',
|
|
56
|
+
'2024-11-05',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// A host may start MCP servers with a trimmed environment, so the variables
|
|
60
|
+
// that locate the relay travel explicitly with the launch. None is a secret:
|
|
61
|
+
// the token stays in the config file the path points at.
|
|
62
|
+
export const OAR_MCP_RELAY_ENV_KEYS = Object.freeze([
|
|
63
|
+
'COPILOT_WEB_RELAY_CONFIG',
|
|
64
|
+
'COPILOT_WEB_RELAY_SERVER_URL',
|
|
65
|
+
'COPILOT_WEB_RELAY_ROOT',
|
|
66
|
+
'COPILOT_WEB_RELAY_SERVER_DIR',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// How long a host should let one tool call run (for hosts that take a
|
|
70
|
+
// per-server timeout, like the Copilot CLI whose default is 180 s).
|
|
71
|
+
export const OAR_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
|
|
72
|
+
|
|
73
|
+
// Progress heartbeats for a long call, sent only when the client asked for
|
|
74
|
+
// progress. Hosts that reset their timeout on progress keep waiting.
|
|
75
|
+
const DEFAULT_PROGRESS_INTERVAL_MS = 25_000;
|
|
76
|
+
|
|
77
|
+
// After the host closes stdin: how long a call still waiting on the relay may
|
|
78
|
+
// keep the process up before it is cut off.
|
|
79
|
+
const SHUTDOWN_GRACE_MS = 2_000;
|
|
80
|
+
|
|
81
|
+
const JSON_RPC = Object.freeze({
|
|
82
|
+
parseError: -32700,
|
|
83
|
+
invalidRequest: -32600,
|
|
84
|
+
methodNotFound: -32601,
|
|
85
|
+
invalidParams: -32602,
|
|
86
|
+
internalError: -32603,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function toText(value) {
|
|
90
|
+
return String(value ?? '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The operator's kill switch: `OAR_MCP_SERVER=0|false|off|no` keeps this
|
|
95
|
+
* server out of every session OAR launches (Grok falls back to its preview
|
|
96
|
+
* instruction block, the extension engine starts without the extra config).
|
|
97
|
+
*/
|
|
98
|
+
export function isOarMcpServerDisabled(env = {}) {
|
|
99
|
+
return /^(0|false|off|no)$/i.test(String(env?.OAR_MCP_SERVER || '').trim());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `--conversation-id <id>` or `--conversation-id=<id>`; '' when absent. */
|
|
103
|
+
export function parseConversationIdArg(argv = []) {
|
|
104
|
+
const args = Array.isArray(argv) ? argv.map((arg) => String(arg ?? '')) : [];
|
|
105
|
+
const index = args.indexOf('--conversation-id');
|
|
106
|
+
if (index !== -1) return toText(args[index + 1]);
|
|
107
|
+
const inline = args.find((arg) => arg.startsWith('--conversation-id='));
|
|
108
|
+
return inline ? toText(inline.slice('--conversation-id='.length)) : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How to start this server for one conversation: `{ command, args, env }`,
|
|
113
|
+
* the neutral shape the Grok ACP wiring and the Copilot extension-engine
|
|
114
|
+
* config are built from. `env` holds only the relay-locating variables that
|
|
115
|
+
* are set in `env`.
|
|
116
|
+
*/
|
|
117
|
+
export function buildOarMcpServerLaunch({
|
|
118
|
+
nodePath = process.execPath,
|
|
119
|
+
conversationId = '',
|
|
120
|
+
env = process.env,
|
|
121
|
+
scriptPath = MODULE_PATH,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const id = toText(conversationId);
|
|
124
|
+
if (!id) throw new Error('oar-mcp-server: a conversation id is required');
|
|
125
|
+
const command = toText(nodePath);
|
|
126
|
+
if (!command) throw new Error('oar-mcp-server: a node executable is required');
|
|
127
|
+
const launchEnv = {};
|
|
128
|
+
for (const key of OAR_MCP_RELAY_ENV_KEYS) {
|
|
129
|
+
const value = toText(env?.[key]);
|
|
130
|
+
if (value) launchEnv[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return { command, args: [toText(scriptPath), '--conversation-id', id], env: launchEnv };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readPackageVersion() {
|
|
136
|
+
try {
|
|
137
|
+
const packagePath = path.resolve(path.dirname(MODULE_PATH), '..', '..', 'package.json');
|
|
138
|
+
return toText(JSON.parse(fs.readFileSync(packagePath, 'utf8'))?.version) || '0.0.0';
|
|
139
|
+
} catch {
|
|
140
|
+
return '0.0.0';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toolDefinitions({ remoteRelay }) {
|
|
145
|
+
return [
|
|
146
|
+
...(remoteRelay ? [{
|
|
147
|
+
name: REMOTE_RELAY_TOOL_NAME,
|
|
148
|
+
description: REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
149
|
+
inputSchema: cloneRemoteRelayToolInputSchema(),
|
|
150
|
+
}] : []),
|
|
151
|
+
{
|
|
152
|
+
name: PREVIEW_TOOL_NAME,
|
|
153
|
+
description: PREVIEW_TOOL_DESCRIPTION,
|
|
154
|
+
inputSchema: JSON.parse(JSON.stringify(PREVIEW_TOOL_INPUT_SCHEMA)),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requestKey(id) {
|
|
160
|
+
return `${typeof id}:${String(id)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The protocol core, transport-free: feed it parsed JSON-RPC messages
|
|
165
|
+
* (`handleMessage`) or raw lines (`handleLine`) and it answers through `send`.
|
|
166
|
+
* `remoteRelay` is the registration decision — a boolean or a promise of one;
|
|
167
|
+
* the tool list waits for it, `initialize` and `ping` do not.
|
|
168
|
+
*/
|
|
169
|
+
export function createOarMcpServer({
|
|
170
|
+
api,
|
|
171
|
+
conversationId = '',
|
|
172
|
+
remoteRelay = false,
|
|
173
|
+
version = '0.0.0',
|
|
174
|
+
send = () => {},
|
|
175
|
+
log = () => {},
|
|
176
|
+
progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS,
|
|
177
|
+
} = {}) {
|
|
178
|
+
const conversation = toText(conversationId);
|
|
179
|
+
const remoteRelayDecision = Promise.resolve(remoteRelay).then((value) => value === true, () => false);
|
|
180
|
+
const inflight = new Map();
|
|
181
|
+
|
|
182
|
+
function reply(id, result) {
|
|
183
|
+
return { jsonrpc: '2.0', id, result };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fail(id, code, message, data) {
|
|
187
|
+
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function listTools() {
|
|
191
|
+
return toolDefinitions({ remoteRelay: await remoteRelayDecision });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function runTool(name, args, signal) {
|
|
195
|
+
if (name === REMOTE_RELAY_TOOL_NAME) {
|
|
196
|
+
const result = await executeRemoteRelayTool(args, { api, conversationId: conversation, signal });
|
|
197
|
+
return formatRemoteRelayToolResult(result);
|
|
198
|
+
}
|
|
199
|
+
const result = await executePreviewTool(args, { api, conversationId: conversation });
|
|
200
|
+
return JSON.stringify(result);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function startProgress(progressToken) {
|
|
204
|
+
if (progressToken === undefined || progressToken === null || !(Number(progressIntervalMs) > 0)) return () => {};
|
|
205
|
+
let progress = 0;
|
|
206
|
+
const timer = setInterval(() => {
|
|
207
|
+
progress += 1;
|
|
208
|
+
send({
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
method: 'notifications/progress',
|
|
211
|
+
params: { progressToken, progress, message: 'Still waiting for the relay…' },
|
|
212
|
+
});
|
|
213
|
+
}, progressIntervalMs);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
return () => clearInterval(timer);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function callTool(id, params) {
|
|
219
|
+
const name = toText(params?.name);
|
|
220
|
+
const tools = await listTools();
|
|
221
|
+
if (!tools.some((tool) => tool.name === name)) {
|
|
222
|
+
return fail(id, JSON_RPC.invalidParams, `Unknown tool: ${name || '(none)'}`);
|
|
223
|
+
}
|
|
224
|
+
const args = params?.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
|
|
225
|
+
? params.arguments
|
|
226
|
+
: {};
|
|
227
|
+
const entry = { cancelled: false, controller: new AbortController() };
|
|
228
|
+
const key = requestKey(id);
|
|
229
|
+
inflight.set(key, entry);
|
|
230
|
+
const stopProgress = startProgress(params?._meta?.progressToken);
|
|
231
|
+
try {
|
|
232
|
+
const text = await runTool(name, args, entry.controller.signal);
|
|
233
|
+
return entry.cancelled ? null : reply(id, { content: [{ type: 'text', text }] });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// The cores never throw by contract; this is the belt to their braces,
|
|
236
|
+
// and still an answer the model can read rather than a protocol error.
|
|
237
|
+
const message = error?.message || String(error);
|
|
238
|
+
log(`tool ${name} failed: ${message}`);
|
|
239
|
+
if (entry.cancelled) return null;
|
|
240
|
+
return reply(id, {
|
|
241
|
+
content: [{ type: 'text', text: formatRemoteRelayToolResult({ ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message }) }],
|
|
242
|
+
isError: true,
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
stopProgress();
|
|
246
|
+
inflight.delete(key);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function handleRequest(message) {
|
|
251
|
+
const { id, method, params } = message;
|
|
252
|
+
switch (method) {
|
|
253
|
+
case 'initialize': {
|
|
254
|
+
const requested = toText(params?.protocolVersion);
|
|
255
|
+
const protocolVersion = OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
256
|
+
? requested
|
|
257
|
+
: OAR_MCP_DEFAULT_PROTOCOL_VERSION;
|
|
258
|
+
return reply(id, {
|
|
259
|
+
protocolVersion,
|
|
260
|
+
capabilities: { tools: {} },
|
|
261
|
+
serverInfo: { name: OAR_MCP_SERVER_NAME, version },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
case 'ping':
|
|
265
|
+
return reply(id, {});
|
|
266
|
+
case 'tools/list':
|
|
267
|
+
return reply(id, { tools: await listTools() });
|
|
268
|
+
case 'tools/call':
|
|
269
|
+
return callTool(id, params);
|
|
270
|
+
default:
|
|
271
|
+
return fail(id, JSON_RPC.methodNotFound, `Method not found: ${method}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A cancelled call gets no answer (the spec's rule), and its relay request is
|
|
276
|
+
// aborted: the relay then withdraws an open approval card and stops waiting
|
|
277
|
+
// on the remote instead of working for nobody.
|
|
278
|
+
function cancel(entry) {
|
|
279
|
+
entry.cancelled = true;
|
|
280
|
+
entry.controller.abort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function handleNotification(message) {
|
|
284
|
+
if (message.method === 'notifications/cancelled') {
|
|
285
|
+
const entry = inflight.get(requestKey(message.params?.requestId));
|
|
286
|
+
if (entry) cancel(entry);
|
|
287
|
+
}
|
|
288
|
+
// notifications/initialized and everything else need no answer.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Cancels every call still running (the host went away); returns how many. */
|
|
292
|
+
function cancelAll() {
|
|
293
|
+
let count = 0;
|
|
294
|
+
for (const entry of inflight.values()) {
|
|
295
|
+
if (entry.cancelled) continue;
|
|
296
|
+
cancel(entry);
|
|
297
|
+
count += 1;
|
|
298
|
+
}
|
|
299
|
+
return count;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One parsed message → its response, or null when none is due. */
|
|
303
|
+
async function handleMessage(message) {
|
|
304
|
+
if (Array.isArray(message)) {
|
|
305
|
+
// Batches (2025-03-26): answer what needs answering, as one array.
|
|
306
|
+
if (message.length === 0) return fail(null, JSON_RPC.invalidRequest, 'Empty batch');
|
|
307
|
+
const responses = (await Promise.all(message.map((entry) => handleMessage(entry)))).filter(Boolean);
|
|
308
|
+
return responses.length ? responses : null;
|
|
309
|
+
}
|
|
310
|
+
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
|
|
311
|
+
return fail(message?.id, JSON_RPC.invalidRequest, 'Invalid Request');
|
|
312
|
+
}
|
|
313
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
314
|
+
if (typeof message.method !== 'string') {
|
|
315
|
+
// A response to a request this server never sends: nothing to do.
|
|
316
|
+
if (hasId && ('result' in message || 'error' in message)) return null;
|
|
317
|
+
return fail(message.id, JSON_RPC.invalidRequest, 'Invalid Request');
|
|
318
|
+
}
|
|
319
|
+
if (!hasId) {
|
|
320
|
+
handleNotification(message);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return await handleRequest(message);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
log(`${message.method} failed: ${error?.message || error}`);
|
|
327
|
+
return fail(message.id, JSON_RPC.internalError, error?.message || 'Internal error');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** One raw stdin line; answers are sent as they are ready (ids correlate). */
|
|
332
|
+
async function handleLine(line) {
|
|
333
|
+
const text = String(line ?? '').trim();
|
|
334
|
+
if (!text) return;
|
|
335
|
+
let message;
|
|
336
|
+
try {
|
|
337
|
+
message = JSON.parse(text);
|
|
338
|
+
} catch {
|
|
339
|
+
send(fail(null, JSON_RPC.parseError, 'Parse error'));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const response = await handleMessage(message);
|
|
343
|
+
if (response) send(response);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { handleLine, handleMessage, listTools, cancelAll };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The stdio process: parse the arguments, reach the relay like a worker does,
|
|
351
|
+
* decide whether `remote_relay` is listed (asked once, at startup), then serve
|
|
352
|
+
* until stdin closes. Returns the server, or null after a startup failure.
|
|
353
|
+
*/
|
|
354
|
+
export async function runOarMcpServer({
|
|
355
|
+
argv = process.argv.slice(2),
|
|
356
|
+
env = process.env,
|
|
357
|
+
stdin = process.stdin,
|
|
358
|
+
stdout = process.stdout,
|
|
359
|
+
stderr = process.stderr,
|
|
360
|
+
createApiClientImpl = createApiClient,
|
|
361
|
+
shouldRegisterRemoteRelayToolImpl = shouldRegisterRemoteRelayTool,
|
|
362
|
+
exit = (code) => process.exit(code),
|
|
363
|
+
} = {}) {
|
|
364
|
+
const log = (message) => {
|
|
365
|
+
try {
|
|
366
|
+
stderr.write(`[oar-mcp ${new Date().toISOString()}] ${message}\n`);
|
|
367
|
+
} catch {
|
|
368
|
+
// stderr gone: nothing else to tell.
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const conversationId = parseConversationIdArg(argv);
|
|
372
|
+
if (!conversationId) {
|
|
373
|
+
log('missing --conversation-id <id>; this server serves exactly one conversation');
|
|
374
|
+
exit(2);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let api;
|
|
379
|
+
try {
|
|
380
|
+
const configPath = toText(env?.COPILOT_WEB_RELAY_CONFIG)
|
|
381
|
+
|| path.resolve(path.dirname(MODULE_PATH), '..', 'config.json');
|
|
382
|
+
const serverUrl = resolveRelayServerUrl({ configPath, env });
|
|
383
|
+
const token = loadTokenFromConfig(configPath);
|
|
384
|
+
api = createApiClientImpl({
|
|
385
|
+
serverUrl,
|
|
386
|
+
token,
|
|
387
|
+
// The conversation this server is bound to, named the way the workers
|
|
388
|
+
// name theirs. No pid headers: this process is not a session worker and
|
|
389
|
+
// must never be taken for one.
|
|
390
|
+
getHeaders: () => ({
|
|
391
|
+
'X-Relay-Session-Id': conversationId,
|
|
392
|
+
'X-Relay-Conversation-Id': conversationId,
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
log(`cannot reach the relay: ${error?.message || error}`);
|
|
397
|
+
exit(1);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let closed = false;
|
|
402
|
+
const send = (message) => {
|
|
403
|
+
if (closed) return;
|
|
404
|
+
try {
|
|
405
|
+
stdout.write(`${JSON.stringify(message)}\n`);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
log(`stdout write failed: ${error?.message || error}`);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const server = createOarMcpServer({
|
|
411
|
+
api,
|
|
412
|
+
conversationId,
|
|
413
|
+
remoteRelay: Promise.resolve()
|
|
414
|
+
.then(() => shouldRegisterRemoteRelayToolImpl({ api }))
|
|
415
|
+
.then((enabled) => {
|
|
416
|
+
log(`serving conversation ${conversationId.slice(0, 8)}; remote_relay ${enabled ? 'on' : 'off'}`);
|
|
417
|
+
return enabled;
|
|
418
|
+
}),
|
|
419
|
+
version: readPackageVersion(),
|
|
420
|
+
send,
|
|
421
|
+
log,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const lines = readline.createInterface({ input: stdin, crlfDelay: Infinity });
|
|
425
|
+
// The host ends the session by closing stdin (or by vanishing: EPIPE). The
|
|
426
|
+
// process then leaves through the event loop rather than process.exit():
|
|
427
|
+
// on Windows an exit in the instant a fetch settles trips a libuv assertion
|
|
428
|
+
// (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and dies with
|
|
429
|
+
// 0xC0000409. Calls still waiting on the relay are cancelled (nobody is
|
|
430
|
+
// left to read their answer, and the relay should stop working for them),
|
|
431
|
+
// which empties the loop; the grace timer is the backstop.
|
|
432
|
+
let shuttingDown = false;
|
|
433
|
+
const shutdown = () => {
|
|
434
|
+
if (shuttingDown) return;
|
|
435
|
+
shuttingDown = true;
|
|
436
|
+
closed = true;
|
|
437
|
+
const cancelled = server.cancelAll();
|
|
438
|
+
if (cancelled) log(`host hung up: cancelled ${cancelled} call(s) still waiting on the relay`);
|
|
439
|
+
lines.close();
|
|
440
|
+
const timer = setTimeout(() => exit(0), SHUTDOWN_GRACE_MS);
|
|
441
|
+
timer.unref?.();
|
|
442
|
+
};
|
|
443
|
+
stdout.on?.('error', (error) => {
|
|
444
|
+
log(`stdout closed: ${error?.message || error}`);
|
|
445
|
+
shutdown();
|
|
446
|
+
});
|
|
447
|
+
lines.on('line', (line) => {
|
|
448
|
+
server.handleLine(line).catch((error) => log(`message handling failed: ${error?.message || error}`));
|
|
449
|
+
});
|
|
450
|
+
lines.on('close', shutdown);
|
|
451
|
+
return server;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isMainModule() {
|
|
455
|
+
const entry = process.argv[1];
|
|
456
|
+
if (!entry) return false;
|
|
457
|
+
try {
|
|
458
|
+
const normalize = (value) => {
|
|
459
|
+
const resolved = fs.realpathSync(path.resolve(value));
|
|
460
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
461
|
+
};
|
|
462
|
+
return normalize(entry) === normalize(MODULE_PATH);
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (isMainModule()) {
|
|
469
|
+
runOarMcpServer().catch((error) => {
|
|
470
|
+
process.stderr.write(`oar-mcp-server fatal: ${error?.stack || error?.message || error}\n`);
|
|
471
|
+
process.exit(1);
|
|
472
|
+
});
|
|
473
|
+
}
|