machine-bridge-mcp 1.2.1 → 1.2.2
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/CHANGELOG.md +9 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/AUDIT.md +12 -0
- package/docs/LOGGING.md +6 -2
- package/docs/OPERATIONS.md +5 -3
- package/docs/UPGRADING.md +4 -2
- package/package.json +1 -1
- package/src/local/call-registry.mjs +9 -3
- package/src/local/log.mjs +10 -2
- package/src/local/relay-connection.mjs +21 -0
- package/src/local/runtime.mjs +64 -26
- package/src/local/tool-executor.mjs +3 -3
- package/src/worker/index.ts +58 -17
- package/src/worker/observability.ts +3 -1
- package/src/worker/pending-calls.ts +17 -0
- package/wrangler.jsonc +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.2 - 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Relay result lifecycle and human-readable diagnostics
|
|
6
|
+
|
|
7
|
+
- Bind every local `tool_result` to the authenticated relay session that delivered its `tool_call`. A result from a disconnected or replaced socket is discarded instead of being sent over a newer connection, and a caller cancellation marks the eventual local result as intentionally undeliverable. Routine late-result races are debug-only rather than repeated `relay.tool_result.delivery_failed` warnings; an actual synchronous WebSocket send failure still invalidates the ambiguous transport.
|
|
8
|
+
- Propagate MCP HTTP cancellation into the Worker pending-call registry. Incoming request cancellation removes both internal and session request-key indexes, sends a best-effort daemon cancellation, and records the call as cancelled. The Worker deployment now explicitly enables Cloudflare request-signal delivery and passthrough to the Durable Object.
|
|
9
|
+
- Add an `unmatched_results` Worker metric for results that arrive after their pending record was removed. Human log mode uses natural-language event messages and omits the redundant machine event key, while JSON mode retains stable event names and bounded structured fields.
|
|
10
|
+
- Add regression coverage for relay-session replacement, stale-result suppression, request-signal cleanup, human log rendering, compatibility flags, and unmatched-result observability. Local Wrangler integration continues to cover the complete OAuth/MCP/WebSocket flow and explicit MCP cancellation.
|
|
11
|
+
|
|
3
12
|
## 1.2.1 - 2026-07-16
|
|
4
13
|
|
|
5
14
|
### Fail-closed input contracts and bounded CLI adapters
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Machine Bridge Browser",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.2",
|
|
5
5
|
"description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"action": {
|
|
31
31
|
"default_title": "Machine Bridge Browser"
|
|
32
32
|
},
|
|
33
|
-
"version_name": "1.2.
|
|
33
|
+
"version_name": "1.2.2"
|
|
34
34
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -34,7 +34,7 @@ A canonical workspace receives an independent profile, Worker name, secret set,
|
|
|
34
34
|
- `runtime-capabilities.mjs` composes agent, application, and browser capability results;
|
|
35
35
|
- managed jobs, local resources, application automation, and browser automation remain separate managers.
|
|
36
36
|
|
|
37
|
-
Architecture tests cap the orchestration module and each extracted service independently and reject a return of low-level process, patch, diagnostic, or capability-scoring logic to `LocalRuntime`. `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, and
|
|
37
|
+
Architecture tests cap the orchestration module and each extracted service independently and reject a return of low-level process, patch, diagnostic, or capability-scoring logic to `LocalRuntime`. `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, outage logging, and a monotonically increasing in-memory session generation. Every relayed tool result is bound to the generation that delivered its call; a disconnected generation cannot send a late result over its replacement socket. Stdio mode invokes `LocalRuntime` directly without that adapter.
|
|
38
38
|
|
|
39
39
|
`daemon-process.mjs` owns workspace-daemon inspection and takeover. It distinguishes platform service state from the lock-owning Node process, validates PID and process-start identity, canonicalizes workspace/state paths before comparison, parses bounded process command lines without executing them, and sends `SIGTERM` only to a verified same-workspace `--daemon-only` process. POSIX daemons may ignore that signal and reach the bounded non-escalating timeout; Node's Windows signal mapping terminates the verified process directly. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
|
|
40
40
|
|
|
@@ -105,7 +105,7 @@ All requests for a deployed Worker route to one named Durable Object. It owns:
|
|
|
105
105
|
|
|
106
106
|
`BridgeRoom` owns Durable Object routing, MCP dispatch, daemon WebSocket lifecycle, and pending relay calls. `OAuthController` owns OAuth-store pruning, registration throttling, authorization pages/submission, account-admin routing, token exchange, access-token verification, and the serialization queue for OAuth mutations. Worker-internal TypeScript imports use explicit `.ts` specifiers and JSON import attributes, so the same modules are directly executable under the pinned Node runtime for focused state-machine tests as well as bundled by Wrangler.
|
|
107
107
|
|
|
108
|
-
The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates cancellation by access-token hash and JSON-RPC ID, and formats text/structured/image results. It has no local filesystem or process API.
|
|
108
|
+
The Worker verifies OAuth, validates MCP envelopes and optional protocol headers, converts `tools/call` into WebSocket messages, correlates cancellation by access-token hash and JSON-RPC ID, and formats text/structured/image results. Pending calls also bind the incoming request `AbortSignal`: an HTTP client disconnect removes the pending indexes and sends a best-effort daemon cancellation. The deployment contract explicitly enables Cloudflare `enable_request_signal` and `request_signal_passthrough` so the signal reaches the named Durable Object. It has no local filesystem or process API.
|
|
109
109
|
|
|
110
110
|
The primary OAuth store separates client registrations and named accounts from authorization codes and access-token records. A separate Durable Object key owns refresh-token records so refresh support can be added without rewriting the live primary-store schema. A `client_id` identifies an MCP application and redirect URIs; account records identify the authorized human or service identity. Codes, access tokens, and refresh tokens bind client ID, account ID, account version, role, scope, resource, token version, and expiration. Only hashes of bearer tokens are persisted, and every public-client refresh invalidates the presented refresh token while returning a replacement. The Worker intersects the role with the active daemon policy before advertising or relaying tools, and the local runtime validates the relayed role again. One bridge-specific Durable Object and one local runtime remain the normal topology for a workspace/trust domain; see [MULTI_ACCOUNT.md](MULTI_ACCOUNT.md).
|
|
111
111
|
|
|
@@ -152,10 +152,10 @@ Remote OAuth binds each code, access token, and refresh token to a named Machine
|
|
|
152
152
|
6. A valid verifier exchanges the one-time code for an expiring access token and refresh token; only their hashes are stored. A refresh request is bound to the original public client, account, scope, resource, and deployment token version, and atomically replaces the refresh token so replay returns `invalid_grant`.
|
|
153
153
|
7. The MCP client initializes against the sole current protocol version; an obsolete client must upgrade rather than enter a legacy execution path. The Worker returns a stateless HMAC-bound `MCP-Session-Id`, and later request/cancellation correlation is scoped by OAuth token, MCP session, JSON-RPC id type, and id value. Two clients may therefore reuse the same JSON-RPC id concurrently without collision. Sessionless POSTs remain independent and are not inserted into a token-global cancellation index. When the daemon advertises `session_bootstrap`, the Worker requests bounded local instructions and appends them to the initialization result; failure degrades to static instructions.
|
|
154
154
|
8. `tools/list` is derived from the active daemon handshake; without a daemon, only `server_info` is advertised.
|
|
155
|
-
9. `tools/call` receives a random relay call ID and is bound to the current socket
|
|
155
|
+
9. `tools/call` receives a random relay call ID and is bound to the current daemon socket, authenticated client request key, incoming HTTP abort signal, and local relay-session generation.
|
|
156
156
|
10. The runtime validates policy and arguments, executes the tool, and returns a bounded result.
|
|
157
|
-
11. The Durable Object accepts a result only from the socket that received that call.
|
|
158
|
-
12. A matching cancellation notification removes the pending
|
|
157
|
+
11. The Durable Object accepts a result only from the daemon socket that received that call, and the local runtime sends it only through the relay generation that delivered the call.
|
|
158
|
+
12. A matching cancellation notification or incoming HTTP client disconnect removes the pending indexes, tells the daemon to cancel, and terminates ordinary child processes bound to the call. If local completion races with cancellation, the eventual result is discarded rather than treated as a user-visible delivery failure.
|
|
159
159
|
13. `start_job` is different: after durable acceptance, the detached runner is no longer bound to the relay call or socket. Later cancellation uses `cancel_job` or the local CLI.
|
|
160
160
|
|
|
161
161
|
Duplicate in-flight JSON-RPC IDs are rejected only within the same authenticated MCP session. The request key includes OAuth token identity, the HMAC-bound MCP session, JSON-RPC id type, and id value, so separate initialized clients may safely reuse the same numeric id.
|
|
@@ -226,7 +226,7 @@ The local `RelayConnection` otherwise treats transport construction, transport o
|
|
|
226
226
|
|
|
227
227
|
Reconnect uses bounded exponential backoff with jitter. Brief self-healing interruptions are debug-only. An unresolved outage is promoted to a rate-limited warning after a grace period, and recovery produces one summary. Raw close codes and reason strings remain debug-only.
|
|
228
228
|
|
|
229
|
-
The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Daemon messages must be valid JSON objects, duplicate `hello` and unknown message types are rejected with explicit protocol errors, and protocol violations close with standard WebSocket status codes. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it.
|
|
229
|
+
The Worker also treats a new connection as a bounded candidate until it authenticates and completes `hello`; a Durable Object alarm enforces the deadline across hibernation. Only a completed candidate replaces the old socket. Daemon messages must be valid JSON objects, duplicate `hello` and unknown message types are rejected with explicit protocol errors, and protocol violations close with standard WebSocket status codes. Pending calls retain their originating socket reference. A stale socket cannot complete or cancel replacement-socket calls. Closing a socket rejects only calls assigned to it. The local relay generation supplies the symmetric boundary: work accepted by an older authenticated connection cannot publish its result through a newer connection even when the call IDs are otherwise well formed.
|
|
230
230
|
|
|
231
231
|
## Persistence
|
|
232
232
|
|
package/docs/AUDIT.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Engineering and security audit
|
|
2
2
|
|
|
3
|
+
## 2026-07-17 version 1.2.2 relay-lifecycle and logging audit
|
|
4
|
+
|
|
5
|
+
The reported warning was the visible symptom of a call-lifecycle mismatch rather than a failed local tool. The Worker could remove a pending call after MCP cancellation, timeout, daemon disconnect, or client abandonment, while the local runtime still completed its asynchronous operation and entered one unconditional result-delivery path. When the original WebSocket had closed, the daemon emitted one `relay.tool_result.delivery_failed` warning per late completion. More seriously, if a replacement relay had already authenticated, the old result could be written to the new socket; the Worker rejected it because its pending record was socket-bound, but the local transport did not enforce the symmetric session boundary.
|
|
6
|
+
|
|
7
|
+
The correction gives each authenticated local relay connection a monotonically increasing in-memory generation and captures that generation with every incoming call. A result may be sent only through the generation that delivered the call. Explicit cancellation and relay disconnect also mark the eventual local result for disposal. Expected cancellation/disconnect/reconnect races are therefore debug-only `relay.tool_result.discarded` events, while a synchronous send failure on the still-current socket continues to invalidate the ambiguous transport and enter the ordinary outage state machine.
|
|
8
|
+
|
|
9
|
+
A second defect existed one layer earlier. The Worker already handled MCP `notifications/cancelled`, but an HTTP client that disconnected without sending that notification could leave the pending internal ID and session request-key index until the tool timeout. The pending-call registry now owns AbortSignal registration and cleanup atomically with its timer and indexes. The Worker deployment explicitly enables Cloudflare `enable_request_signal` and `request_signal_passthrough`, which are both required because the public Worker forwards the request into a named Durable Object. A deterministic registry test covers active and already-aborted requests; the complete local workerd suite continues to cover OAuth, explicit MCP cancellation, WebSocket routing, timeout, and socket loss.
|
|
10
|
+
|
|
11
|
+
Logging was reviewed as a user interface rather than a serialization dump. Human mode now uses the supplied natural-language explanation and omits the redundant stable event identifier; newline-delimited JSON retains that identifier for ingestion. Tool starts, outcomes, cancellation, timing, and late-result disposal remain debug-only. Worker observability adds an `unmatched_results` counter for a result that arrives after its pending record has been removed, preserving anomaly evidence without storing tool arguments, command text, paths, or result content.
|
|
12
|
+
|
|
13
|
+
The broader pass rechecked architecture boundaries, cancellation and reconnect branches, release/configuration contracts, privacy redaction, package version convergence, TypeScript and checked-JavaScript contracts, lint, Worker integration, and documentation drift. An additional deployment defect was found during fault reproduction: request cancellation support was implemented in code but the required Cloudflare compatibility flags were absent, so the signal could not reach the Durable Object in production. Architecture tests now make both flags non-optional. Version 1.2.2 retains local state schema 6 and policy revision 5; normal startup must converge the Worker and daemon versions, and the unpacked browser extension must be reloaded. No live deployment, credential rotation, global installation, daemon replacement, npm publication, tag, or GitHub Release is performed by this source change.
|
|
14
|
+
|
|
3
15
|
## 2026-07-16 version 1.2.1 fail-closed input-contract audit
|
|
4
16
|
|
|
5
17
|
The review began from clean `v1.2.0` with the complete repository gate, dependency audit, Worker dry run, and package checks passing. Hostile-input reproduction nevertheless found one shared JavaScript failure mechanism outside those gates: ordinary objects were being used as external string-key maps. Inherited names such as `constructor` and `__proto__` were therefore accepted as account roles, policy profiles, top-level commands, resource actions, and keyboard-table entries. The CLI variants could exit successfully without performing a command; a Worker account could persist an invalid role and later fail during authority calculation. Adjacent review found the same semantic hazard in form-field aggregation and valid local-resource names.
|
package/docs/LOGGING.md
CHANGED
|
@@ -26,6 +26,8 @@ The CLI accepts:
|
|
|
26
26
|
|
|
27
27
|
Foreground mode defaults to `info` and human-readable text. Platform autostart services use `warn` with newline-delimited JSON. Foreground operators can select JSON explicitly with `--log-format json`.
|
|
28
28
|
|
|
29
|
+
Human mode treats the message as the primary interface: it uses a natural-language explanation and includes only bounded diagnostic fields that add meaning. It does not repeat the machine event key. JSON mode retains the stable `event` field for ingestion and correlation. Event identifiers such as `relay.tool_result.discarded` are implementation contracts for structured logs, not text that should be shown as the warning itself.
|
|
30
|
+
|
|
29
31
|
| Level | Intended events |
|
|
30
32
|
|---|---|
|
|
31
33
|
| `error` | The operation or service cannot continue without intervention |
|
|
@@ -73,9 +75,11 @@ Examples:
|
|
|
73
75
|
|
|
74
76
|
With `--verbose`, the same incident additionally includes bounded structured fields such as exact seconds, coarse error class, retry delay, and transport close diagnostics.
|
|
75
77
|
|
|
76
|
-
## Tool events
|
|
78
|
+
## Tool and result-delivery events
|
|
79
|
+
|
|
80
|
+
All per-tool starts, successes, failures, cancellations, timing, and expected late-result disposal are debug-only. The MCP response already reports the outcome to the caller; duplicating routine tool traffic at default levels creates noise and can reveal activity patterns.
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
A completed local result is bound to the authenticated relay session that delivered its call. If the caller cancelled, the relay disconnected, or a replacement relay session became active, the result is discarded rather than sent to a different session. This is a normal terminal race and does not emit a warning. Debug output records only a shortened call ID and a coarse reason such as `caller_cancelled`, `relay_disconnected`, or `session_ended`. A synchronous send failure on the still-current socket is different: the socket is invalidated because delivery is ambiguous, but the per-call detail remains debug-only while the normal relay-outage state machine decides whether a user-visible warning is warranted.
|
|
79
83
|
|
|
80
84
|
Debug per-tool fields may include tool name, duration, coarse outcome class, and a shortened random call identifier. The identifier is for correlating adjacent local events and is not a stable audit identifier.
|
|
81
85
|
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -50,7 +50,9 @@ A successful diagnostic result applies only to that probe. An MCP host can still
|
|
|
50
50
|
|
|
51
51
|
Machine Bridge supports concurrent calls: the Worker admits up to 32 pending daemon calls and the local runtime admits up to 16 active tool calls. These are capacity limits, not a single global execution queue. Each successful MCP initialization receives a signed `MCP-Session-Id`; JSON-RPC ids and cancellation are scoped to that session, so separate chat windows may reuse the same numeric ids safely even when they share one OAuth account and token.
|
|
52
52
|
|
|
53
|
-
`server_info.worker.pending_calls` reports `active`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. A nonzero `active` count means work is in flight, not that the bridge is locked. Calls for simple reads and probes should continue while another independent process call runs.
|
|
53
|
+
`server_info.worker.pending_calls` reports `active`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. A nonzero `active` count means work is in flight, not that the bridge is locked. Calls for simple reads and probes should continue while another independent process call runs. Explicit MCP cancellation, an incoming HTTP client disconnect, timeout, and daemon-socket closure remove the pending record and its session request key. Local results are bound to the authenticated relay session that originated the call, so a result that finishes after cancellation or reconnection is discarded instead of being sent over a replacement socket. Refreshing a chat page is not the recovery mechanism and should not be required.
|
|
54
|
+
|
|
55
|
+
`server_info.worker.observability.calls.unmatched_results` counts results that reached the Worker after their pending record was already removed. A small increase can accompany cancellation or timeout races, especially during mixed-version upgrade convergence; sustained growth together with old pending calls indicates incompatible components or a lifecycle defect. The counter contains no tool arguments or result data.
|
|
54
56
|
|
|
55
57
|
### Relay interruption messages
|
|
56
58
|
|
|
@@ -144,7 +146,7 @@ Uninstall acquires a state-root `maintenance.lock` that blocks new profile/state
|
|
|
144
146
|
|
|
145
147
|
### Lifecycle and pending-call diagnosis
|
|
146
148
|
|
|
147
|
-
`server_info.runtime.lifecycle` reports `ready`, `starting`, `running`, `failed`, `stopping`, or `stopped`. `runtime.observability.in_flight_calls` and `runtime.observability.processes` distinguish a blocked call from a surviving process. Worker `server_info.worker.pending_calls` reports both the internal-call index and client request-key index; they must return to zero after a terminal result, cancellation, timeout, send failure, or daemon disconnect. Nonzero request-key counts after active calls reach zero indicate a lifecycle defect rather than normal load.
|
|
149
|
+
`server_info.runtime.lifecycle` reports `ready`, `starting`, `running`, `failed`, `stopping`, or `stopped`. `runtime.observability.in_flight_calls` and `runtime.observability.processes` distinguish a blocked call from a surviving process. Worker `server_info.worker.pending_calls` reports both the internal-call index and client request-key index; they must return to zero after a terminal result, explicit cancellation, client disconnect, timeout, send failure, or daemon disconnect. Nonzero request-key counts after active calls reach zero indicate a lifecycle defect rather than normal load. `worker.observability.calls.unmatched_results` is the bounded counter for late results that no longer have a receiver.
|
|
148
150
|
|
|
149
151
|
Stable errors include `policy_denied`, `invalid_request`, `timeout`, `cancelled`, `network_error`, `unavailable`, `limit_exceeded`, and `integrity_error`, with retryability metadata. Diagnose by code first; free-form messages are guidance, not an API contract.
|
|
150
152
|
|
|
@@ -161,7 +163,7 @@ Logging is level-based:
|
|
|
161
163
|
error unrecoverable local/transport/service failures
|
|
162
164
|
warn persistent retryable relay degradation, supersession, and service problems
|
|
163
165
|
info startup/deploy/connect transitions
|
|
164
|
-
debug all per-tool starts/successes/failures/cancellations/timing, correlation and reconnect details
|
|
166
|
+
debug all per-tool starts/successes/failures/cancellations/timing, late-result disposal, correlation and reconnect details
|
|
165
167
|
```
|
|
166
168
|
|
|
167
169
|
Foreground mode defaults to `info`; autostart uses `warn`. Use `--verbose` or `--log-level debug` only for diagnosis. `--quiet` is an alias for `--log-level error`.
|
package/docs/UPGRADING.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
Machine Bridge does not retain parallel implementations for obsolete MCP protocol dates, policy revisions, state schemas, lock formats, or browser-extension protocols. The supported path is a direct upgrade from the immediately preceding published package while its state already uses the current schema.
|
|
6
6
|
|
|
7
|
-
Version 1.2.
|
|
7
|
+
Version 1.2.2 keeps local state schema version 6 and policy revision 5 unchanged. Existing 1.2.1 workspaces, named accounts, resource registrations, managed-job history, Worker identity, and browser pairing state are reused without a schema conversion. The package change affects relay call/result lifetime, Worker request cancellation, logging presentation, and observability; it does not alter the stored authority model.
|
|
8
8
|
|
|
9
|
-
Version 1.2.
|
|
9
|
+
Version 1.2.2 requires the Worker, local daemon, and browser extension to converge on the same package version. Normal startup redeploys the versioned Worker when required. The Worker configuration now enables Cloudflare request-signal delivery and signal passthrough to the Durable Object; an old Worker therefore does not provide the new HTTP-disconnect cancellation path even when the local daemon has already been upgraded.
|
|
10
|
+
|
|
11
|
+
Version 1.2.0 could accept prototype-shaped account roles through malformed administration input. On the first 1.2.1 or later Worker access, such an account is preserved for recovery but repaired fail-closed: its role becomes `reviewer`, it is disabled, its account version advances, and its authorization codes and tokens are removed. An operator can then assign a valid role, enable the account, and rotate its password through the normal account administration flow. A local policy record with an unknown profile label is normalized to `custom` while retaining its explicit capability fields; an invalid explicit `--profile` is rejected.
|
|
10
12
|
|
|
11
13
|
A state file from an older unsupported schema is rejected rather than guessed or silently rewritten. Upgrade an old installation through the last release that understands its schema, or initialize a new workspace and re-register resources. Do not edit schema numbers by hand.
|
|
12
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -100,12 +100,18 @@ export class CallRegistry {
|
|
|
100
100
|
return true;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** @param {unknown} origin */
|
|
104
|
+
idsByOrigin(origin) {
|
|
105
|
+
const expected = String(origin || "");
|
|
106
|
+
return [...this.calls.values()]
|
|
107
|
+
.filter((record) => record.origin === expected)
|
|
108
|
+
.map((record) => record.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
/** @param {unknown} origin @param {unknown} [reason] */
|
|
104
112
|
cancelOrigin(origin, reason = "transport disconnected") {
|
|
105
|
-
const expected = String(origin || "");
|
|
106
113
|
let cancelled = 0;
|
|
107
|
-
for (const
|
|
108
|
-
if (record.origin !== expected) continue;
|
|
114
|
+
for (const id of this.idsByOrigin(origin)) {
|
|
109
115
|
if (this.cancel(id, reason)) cancelled += 1;
|
|
110
116
|
}
|
|
111
117
|
return cancelled;
|
package/src/local/log.mjs
CHANGED
|
@@ -55,7 +55,7 @@ export function createLogger(options = {}) {
|
|
|
55
55
|
const normalizedLevel = normalizeEventLevel(level);
|
|
56
56
|
const eventName = sanitizeEventName(name);
|
|
57
57
|
const payload = { event: eventName, ...fields };
|
|
58
|
-
const humanMessage = message || eventName;
|
|
58
|
+
const humanMessage = message || humanizeEventName(eventName);
|
|
59
59
|
if (options.format === "json") {
|
|
60
60
|
if (LEVEL_RANK[normalizedLevel] < LEVEL_RANK[minimumLevel]) return;
|
|
61
61
|
const entry = sanitizeLogValue({
|
|
@@ -77,7 +77,7 @@ export function createLogger(options = {}) {
|
|
|
77
77
|
error: [stderr, "[error]", COLORS.red],
|
|
78
78
|
};
|
|
79
79
|
const [stream, label, color] = methods[normalizedLevel];
|
|
80
|
-
write(stream, normalizedLevel, label, color, humanMessage,
|
|
80
|
+
write(stream, normalizedLevel, label, color, humanMessage, fields);
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
return {
|
|
@@ -189,6 +189,14 @@ function normalizeEventLevel(value) {
|
|
|
189
189
|
return Object.prototype.hasOwnProperty.call(LEVEL_RANK, level) ? level : "info";
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
function humanizeEventName(value) {
|
|
193
|
+
const words = String(value || "event")
|
|
194
|
+
.replace(/[._-]+/g, " ")
|
|
195
|
+
.replace(/\s+/g, " ")
|
|
196
|
+
.trim();
|
|
197
|
+
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "Event";
|
|
198
|
+
}
|
|
199
|
+
|
|
192
200
|
function sanitizeEventName(value) {
|
|
193
201
|
const name = String(value || "event").toLowerCase().replace(/[^a-z0-9._-]/g, "_").slice(0, 128);
|
|
194
202
|
return name || "event";
|
|
@@ -73,6 +73,8 @@ export class RelayConnection {
|
|
|
73
73
|
this.connectedOnce = null;
|
|
74
74
|
this.connectedOnceResolve = null;
|
|
75
75
|
this.connectedOnceReject = null;
|
|
76
|
+
this.sessionGeneration = 0;
|
|
77
|
+
this.activeSessionId = 0;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
status() {
|
|
@@ -82,9 +84,14 @@ export class RelayConnection {
|
|
|
82
84
|
network_route: this.networkRoute,
|
|
83
85
|
reconnect_attempt: this.reconnectAttempt,
|
|
84
86
|
outage_active: this.outageStartedAt > 0,
|
|
87
|
+
session_generation: this.sessionGeneration,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
currentSessionId() {
|
|
92
|
+
return this.ready ? this.activeSessionId : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
start() {
|
|
89
96
|
if (!this.closed && this.connectedOnce) return this.connectedOnce;
|
|
90
97
|
this.closed = false;
|
|
@@ -99,6 +106,7 @@ export class RelayConnection {
|
|
|
99
106
|
stop() {
|
|
100
107
|
this.closed = true;
|
|
101
108
|
this.ready = false;
|
|
109
|
+
this.activeSessionId = 0;
|
|
102
110
|
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
103
111
|
this.clearTimer("connectTimer", "clearTimeout");
|
|
104
112
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
@@ -118,6 +126,15 @@ export class RelayConnection {
|
|
|
118
126
|
return this.sendOnSocket(this.socket, value);
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
sendForSession(value, expectedSessionId) {
|
|
130
|
+
const sessionId = Number(expectedSessionId) || 0;
|
|
131
|
+
if (!sessionId || sessionId !== this.activeSessionId) return { ok: false, reason: "session_ended" };
|
|
132
|
+
if (!this.ready || !this.isSocketOpen(this.socket)) return { ok: false, reason: "transport_unavailable" };
|
|
133
|
+
return this.sendOnSocket(this.socket, value)
|
|
134
|
+
? { ok: true, reason: "sent" }
|
|
135
|
+
: { ok: false, reason: "send_failed" };
|
|
136
|
+
}
|
|
137
|
+
|
|
121
138
|
interrupt(category = "relay_transport_error") {
|
|
122
139
|
if (this.closed || !this.socket) return false;
|
|
123
140
|
this.pendingCloseCategory = String(category || "relay_transport_error");
|
|
@@ -148,6 +165,8 @@ export class RelayConnection {
|
|
|
148
165
|
return false;
|
|
149
166
|
}
|
|
150
167
|
this.ready = true;
|
|
168
|
+
this.sessionGeneration += 1;
|
|
169
|
+
this.activeSessionId = this.sessionGeneration;
|
|
151
170
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
152
171
|
this.connectedAt = this.now();
|
|
153
172
|
this.lastInboundAt = this.connectedAt;
|
|
@@ -268,6 +287,7 @@ export class RelayConnection {
|
|
|
268
287
|
const wasReady = this.ready;
|
|
269
288
|
this.socket = null;
|
|
270
289
|
this.ready = false;
|
|
290
|
+
this.activeSessionId = 0;
|
|
271
291
|
this.clearTimer("connectTimer", "clearTimeout");
|
|
272
292
|
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
273
293
|
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
@@ -329,6 +349,7 @@ export class RelayConnection {
|
|
|
329
349
|
const socket = this.socket;
|
|
330
350
|
this.closed = true;
|
|
331
351
|
this.ready = false;
|
|
352
|
+
this.activeSessionId = 0;
|
|
332
353
|
this.socket = null;
|
|
333
354
|
this.clearTimer("connectTimer", "clearTimeout");
|
|
334
355
|
this.clearTimer("heartbeatTimer", "clearInterval");
|
package/src/local/runtime.mjs
CHANGED
|
@@ -113,6 +113,8 @@ export class LocalRuntime {
|
|
|
113
113
|
this.processTracker = new ProcessTracker();
|
|
114
114
|
this.lifecycle = new LifecycleController("local runtime");
|
|
115
115
|
this.observability = new RuntimeObservability();
|
|
116
|
+
this.activeRelayCalls = new Set();
|
|
117
|
+
this.suppressedRelayResults = new Map();
|
|
116
118
|
this.callRegistry = new CallRegistry({
|
|
117
119
|
maximum: MAX_CONCURRENT_TOOL_CALLS,
|
|
118
120
|
onCancel: (record) => {
|
|
@@ -121,7 +123,7 @@ export class LocalRuntime {
|
|
|
121
123
|
this.processTracker.terminateCall(record.id);
|
|
122
124
|
this.logger.event?.("debug", "tool.call.cancel_requested", {
|
|
123
125
|
call_id: shortCallId(record.id), tool: record.tool, origin: record.origin,
|
|
124
|
-
});
|
|
126
|
+
}, "Tool call cancellation requested");
|
|
125
127
|
},
|
|
126
128
|
onFinish: (record) => this.processTracker.releaseCall(record.id),
|
|
127
129
|
});
|
|
@@ -289,7 +291,7 @@ export class LocalRuntime {
|
|
|
289
291
|
return this.relay?.send(value) === true;
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
async handleMessage(raw) {
|
|
294
|
+
async handleMessage(raw, relayContext = {}) {
|
|
293
295
|
let message;
|
|
294
296
|
try { message = JSON.parse(raw); } catch {
|
|
295
297
|
this.handleRelayProtocolViolation("invalid_server_json");
|
|
@@ -304,7 +306,7 @@ export class LocalRuntime {
|
|
|
304
306
|
this.handleRelayProtocolViolation("unexpected_server_message_type");
|
|
305
307
|
return;
|
|
306
308
|
}
|
|
307
|
-
await this.handleRelayToolCall(message);
|
|
309
|
+
await this.handleRelayToolCall(message, relayContext);
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
handleRelayControlMessage(message) {
|
|
@@ -322,7 +324,7 @@ export class LocalRuntime {
|
|
|
322
324
|
return true;
|
|
323
325
|
}
|
|
324
326
|
if (message.type === "cancel_call") {
|
|
325
|
-
if (typeof message.id === "string") this.
|
|
327
|
+
if (typeof message.id === "string") this.cancelRelayCall(message.id, "caller_cancelled");
|
|
326
328
|
return true;
|
|
327
329
|
}
|
|
328
330
|
return false;
|
|
@@ -336,37 +338,65 @@ export class LocalRuntime {
|
|
|
336
338
|
this.logger.error?.("remote relay protocol error; upgrade and redeploy both components, then restart the daemon");
|
|
337
339
|
}
|
|
338
340
|
|
|
339
|
-
async handleRelayToolCall(message) {
|
|
341
|
+
async handleRelayToolCall(message, relayContext = {}) {
|
|
340
342
|
const envelope = normalizeRelayToolCall(message);
|
|
343
|
+
const relaySessionId = Number(relayContext.sessionId) || 0;
|
|
341
344
|
if (!envelope.ok) {
|
|
342
|
-
this.logger.
|
|
345
|
+
this.logger.warn?.("Received an invalid tool request from the relay; the request was rejected.");
|
|
346
|
+
this.logger.event?.("debug", "relay.tool_call.invalid", {
|
|
347
|
+
has_call_id: Boolean(envelope.id),
|
|
348
|
+
}, "Invalid relay tool request details");
|
|
343
349
|
if (envelope.id) this.deliverRelayToolResult({
|
|
344
350
|
type: "tool_result",
|
|
345
351
|
id: envelope.id,
|
|
346
352
|
ok: false,
|
|
347
353
|
error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
|
|
348
|
-
});
|
|
354
|
+
}, relaySessionId);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (this.activeRelayCalls.has(envelope.id)) {
|
|
358
|
+
this.handleRelayProtocolViolation("duplicate_tool_call_id");
|
|
349
359
|
return;
|
|
350
360
|
}
|
|
351
|
-
|
|
361
|
+
this.activeRelayCalls.add(envelope.id);
|
|
352
362
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
let response;
|
|
364
|
+
try {
|
|
365
|
+
const result = await this.executeTool(envelope.tool, envelope.arguments, {
|
|
366
|
+
callId: envelope.id,
|
|
367
|
+
origin: "relay",
|
|
368
|
+
timeoutMs: envelope.timeoutMs,
|
|
369
|
+
authorization: envelope.authorization,
|
|
370
|
+
});
|
|
371
|
+
response = { type: "tool_result", id: envelope.id, ok: true, result };
|
|
372
|
+
} catch (error) {
|
|
373
|
+
response = { type: "tool_result", id: envelope.id, ok: false, error: publicError(error) };
|
|
374
|
+
}
|
|
375
|
+
const suppressionReason = this.suppressedRelayResults.get(envelope.id) || "";
|
|
376
|
+
if (suppressionReason) {
|
|
377
|
+
this.logger.event?.("debug", "relay.tool_result.discarded", {
|
|
378
|
+
call_id: shortCallId(envelope.id), reason: suppressionReason,
|
|
379
|
+
}, "Discarded a tool result because the caller was no longer waiting");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
this.deliverRelayToolResult(response, relaySessionId);
|
|
383
|
+
} finally {
|
|
384
|
+
this.activeRelayCalls.delete(envelope.id);
|
|
385
|
+
this.suppressedRelayResults.delete(envelope.id);
|
|
362
386
|
}
|
|
363
|
-
this.deliverRelayToolResult(response);
|
|
364
387
|
}
|
|
365
388
|
|
|
366
|
-
deliverRelayToolResult(response) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
389
|
+
deliverRelayToolResult(response, relaySessionId = 0) {
|
|
390
|
+
const outcome = this.relay?.sendForSession?.(response, relaySessionId)
|
|
391
|
+
|| (this.send(response) ? { ok: true, reason: "sent" } : { ok: false, reason: "transport_unavailable" });
|
|
392
|
+
if (outcome.ok) return true;
|
|
393
|
+
const reason = String(outcome.reason || "transport_unavailable");
|
|
394
|
+
this.logger.event?.("debug", "relay.tool_result.discarded", {
|
|
395
|
+
call_id: shortCallId(response?.id), reason,
|
|
396
|
+
}, reason === "send_failed"
|
|
397
|
+
? "Could not send a tool result because the relay transport failed"
|
|
398
|
+
: "Discarded a tool result because its relay session had ended");
|
|
399
|
+
if (reason === "send_failed") this.relay?.interrupt?.("relay_transport_error");
|
|
370
400
|
return false;
|
|
371
401
|
}
|
|
372
402
|
|
|
@@ -379,11 +409,19 @@ export class LocalRuntime {
|
|
|
379
409
|
return this.callRegistry.cancel(callId, reason);
|
|
380
410
|
}
|
|
381
411
|
|
|
412
|
+
cancelRelayCall(callId, suppressionReason = "caller_cancelled") {
|
|
413
|
+
const id = String(callId);
|
|
414
|
+
if (this.activeRelayCalls.has(id)) this.suppressedRelayResults.set(id, suppressionReason);
|
|
415
|
+
return this.cancelCall(id, "remote cancellation");
|
|
416
|
+
}
|
|
417
|
+
|
|
382
418
|
handleRelayDisconnect() {
|
|
419
|
+
for (const callId of this.activeRelayCalls) this.suppressedRelayResults.set(callId, "relay_disconnected");
|
|
383
420
|
const cancelled = this.callRegistry.cancelOrigin("relay", "remote relay disconnected");
|
|
384
421
|
this.terminateActiveProcesses("SIGTERM", true);
|
|
385
422
|
if (cancelled > 0) {
|
|
386
|
-
this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled }
|
|
423
|
+
this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled },
|
|
424
|
+
"Cancelled in-flight tool calls after the relay connection ended");
|
|
387
425
|
}
|
|
388
426
|
}
|
|
389
427
|
|
|
@@ -669,7 +707,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
669
707
|
policy: runtime.policy,
|
|
670
708
|
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
671
709
|
}),
|
|
672
|
-
onMessage: (data) => handleRelayData(runtime, data),
|
|
710
|
+
onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
|
|
673
711
|
onDisconnect: () => runtime.handleRelayDisconnect(),
|
|
674
712
|
onSuperseded: () => {
|
|
675
713
|
runtime.terminateActiveProcesses("SIGKILL");
|
|
@@ -684,13 +722,13 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
684
722
|
});
|
|
685
723
|
}
|
|
686
724
|
|
|
687
|
-
function handleRelayData(runtime, data) {
|
|
725
|
+
function handleRelayData(runtime, data, relayContext = {}) {
|
|
688
726
|
const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
689
727
|
if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
|
690
728
|
runtime.handleRelayProtocolViolation("server_message_too_large");
|
|
691
729
|
return;
|
|
692
730
|
}
|
|
693
|
-
return runtime.handleMessage(raw);
|
|
731
|
+
return runtime.handleMessage(raw, relayContext);
|
|
694
732
|
}
|
|
695
733
|
|
|
696
734
|
function normalizeRelayToolCall(message) {
|
|
@@ -67,7 +67,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
67
67
|
call_id: shortCallId(operation.context.callId),
|
|
68
68
|
tool: operation.tool,
|
|
69
69
|
origin: operation.context.origin,
|
|
70
|
-
});
|
|
70
|
+
}, "Tool call started");
|
|
71
71
|
try {
|
|
72
72
|
const result = await next(operation);
|
|
73
73
|
const durationMs = performance.now() - started;
|
|
@@ -75,7 +75,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
75
75
|
observability.finish(operation.tool, { status: "completed", durationMs, slow });
|
|
76
76
|
logger.event?.("debug", slow ? "tool.call.slow" : "tool.call.completed", {
|
|
77
77
|
call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin, duration_ms: durationMs,
|
|
78
|
-
});
|
|
78
|
+
}, slow ? "Tool call completed slowly" : "Tool call completed");
|
|
79
79
|
return result;
|
|
80
80
|
} catch (error) {
|
|
81
81
|
const normalized = normalizeBridgeError(error, { safeMessage: () => safeMessage(error, operation.args) });
|
|
@@ -86,7 +86,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
86
86
|
logger.event?.("debug", "tool.call.failed", {
|
|
87
87
|
call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin,
|
|
88
88
|
duration_ms: durationMs, error_code: code, retryable: normalized.retryable,
|
|
89
|
-
});
|
|
89
|
+
}, "Tool call failed");
|
|
90
90
|
throw normalized;
|
|
91
91
|
}
|
|
92
92
|
};
|
package/src/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./http.ts";
|
|
18
18
|
|
|
19
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "1.2.
|
|
20
|
+
const SERVER_VERSION = "1.2.2";
|
|
21
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -220,8 +220,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
const matched = body.ok === false
|
|
224
|
+
? this.pending.reject(body.id, daemonToolError(body.error), ws)
|
|
225
|
+
: this.pending.resolve(body.id, ws, body.result);
|
|
226
|
+
if (!matched) this.observability.unmatchedResult();
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
@@ -269,19 +271,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
269
271
|
|
|
270
272
|
const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
|
|
271
273
|
if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
|
|
272
|
-
const response = await this.dispatchJsonRpc(
|
|
274
|
+
const response = await this.dispatchJsonRpc(
|
|
275
|
+
body,
|
|
276
|
+
base,
|
|
277
|
+
authorized,
|
|
278
|
+
session.kind === "active" ? session.sessionId : "",
|
|
279
|
+
request.signal,
|
|
280
|
+
);
|
|
273
281
|
if (response === null) return new Response(null, { status: 202 });
|
|
274
282
|
return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
private async dispatchJsonRpc(
|
|
285
|
+
private async dispatchJsonRpc(
|
|
286
|
+
request: JsonRpcRequest,
|
|
287
|
+
base: string,
|
|
288
|
+
authorized: AuthorizedToken,
|
|
289
|
+
sessionId: string,
|
|
290
|
+
signal?: AbortSignal,
|
|
291
|
+
): Promise<Record<string, unknown> | null> {
|
|
278
292
|
if (request.method === "initialize") {
|
|
279
293
|
const requested = asObject(request.params).protocolVersion;
|
|
280
294
|
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
281
295
|
? requested
|
|
282
296
|
: MCP_PROTOCOL_VERSION;
|
|
283
297
|
const bootstrap = this.daemonToolEnabled("session_bootstrap")
|
|
284
|
-
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
|
|
298
|
+
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized, undefined, signal).catch(() => null)
|
|
285
299
|
: null;
|
|
286
300
|
const localInstructions = sessionInstructionText(bootstrap);
|
|
287
301
|
return rpcResult(request.id, {
|
|
@@ -310,7 +324,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
310
324
|
const name = requiredString(params, "name");
|
|
311
325
|
const args = asObject(params.arguments);
|
|
312
326
|
try {
|
|
313
|
-
const result = await this.callTool(
|
|
327
|
+
const result = await this.callTool(
|
|
328
|
+
name,
|
|
329
|
+
args,
|
|
330
|
+
base,
|
|
331
|
+
authorized,
|
|
332
|
+
mcpClientRequestKey(authorized.tokenKey, sessionId, request.id),
|
|
333
|
+
signal,
|
|
334
|
+
);
|
|
314
335
|
return rpcResult(request.id, textToolResult(result));
|
|
315
336
|
} catch (error) {
|
|
316
337
|
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
@@ -318,7 +339,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
318
339
|
}
|
|
319
340
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
320
341
|
}
|
|
321
|
-
private async callTool(
|
|
342
|
+
private async callTool(
|
|
343
|
+
name: string,
|
|
344
|
+
args: Record<string, unknown>,
|
|
345
|
+
base: string,
|
|
346
|
+
authorized: AuthorizedToken,
|
|
347
|
+
requestKey?: string,
|
|
348
|
+
signal?: AbortSignal,
|
|
349
|
+
): Promise<unknown> {
|
|
322
350
|
if (name === "server_info") {
|
|
323
351
|
const { daemon, tools, authorization } = this.authorityContext(authorized);
|
|
324
352
|
return {
|
|
@@ -351,13 +379,19 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
351
379
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
352
380
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
353
381
|
if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
|
|
354
|
-
const result = await this.callDaemonTool(name, args, authorized, requestKey);
|
|
382
|
+
const result = await this.callDaemonTool(name, args, authorized, requestKey, signal);
|
|
355
383
|
return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
|
|
356
384
|
accountVersion: authorized.accountVersion, role: authorized.role }) : result;
|
|
357
385
|
}
|
|
358
386
|
throw new Error(`unknown tool: ${name}`);
|
|
359
387
|
}
|
|
360
|
-
private async callDaemonTool(
|
|
388
|
+
private async callDaemonTool(
|
|
389
|
+
name: string,
|
|
390
|
+
args: Record<string, unknown>,
|
|
391
|
+
authorized: AuthorizedToken,
|
|
392
|
+
requestKey?: string,
|
|
393
|
+
signal?: AbortSignal,
|
|
394
|
+
): Promise<unknown> {
|
|
361
395
|
const socket = this.daemonSockets()[0];
|
|
362
396
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
363
397
|
const id = randomToken("call");
|
|
@@ -374,6 +408,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
374
408
|
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
375
409
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
376
410
|
},
|
|
411
|
+
signal,
|
|
412
|
+
onAbort: (record) => {
|
|
413
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
414
|
+
return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
|
|
415
|
+
},
|
|
377
416
|
});
|
|
378
417
|
} catch (error) {
|
|
379
418
|
if (error instanceof PendingCallRegistrationError) {
|
|
@@ -382,13 +421,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
382
421
|
throw error;
|
|
383
422
|
}
|
|
384
423
|
this.observability.callStarted(name);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
424
|
+
if (!signal?.aborted) {
|
|
425
|
+
try {
|
|
426
|
+
socket.send(JSON.stringify({
|
|
427
|
+
type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
|
|
428
|
+
authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
|
|
429
|
+
}));
|
|
430
|
+
} catch {
|
|
431
|
+
this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
|
|
432
|
+
}
|
|
392
433
|
}
|
|
393
434
|
try {
|
|
394
435
|
const value = await result;
|
|
@@ -5,7 +5,7 @@ const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|toke
|
|
|
5
5
|
export class WorkerObservability {
|
|
6
6
|
private readonly startedAt = performance.now();
|
|
7
7
|
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
|
-
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
8
|
+
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0, unmatched_results: 0 };
|
|
9
9
|
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
10
10
|
private readonly errors = new Map<string, number>();
|
|
11
11
|
private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
|
|
@@ -39,6 +39,8 @@ export class WorkerObservability {
|
|
|
39
39
|
this.incrementError(code);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
unmatchedResult(): void { this.calls.unmatched_results += 1; }
|
|
43
|
+
|
|
42
44
|
socketCandidate(): void { this.sockets.candidates += 1; }
|
|
43
45
|
socketAuthenticated(): void { this.sockets.authenticated += 1; }
|
|
44
46
|
socketDisconnected(): void { this.sockets.disconnected += 1; }
|
|
@@ -19,6 +19,8 @@ export interface PendingCallRecord {
|
|
|
19
19
|
timeout: ReturnType<typeof setTimeout>;
|
|
20
20
|
resolve: (value: unknown) => void;
|
|
21
21
|
reject: (error: Error) => void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
abortHandler?: () => void;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface RegisterPendingCall {
|
|
@@ -28,6 +30,8 @@ interface RegisterPendingCall {
|
|
|
28
30
|
tool: string;
|
|
29
31
|
timeoutMs: number;
|
|
30
32
|
onTimeout: (record: PendingCallRecord) => Error;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
onAbort?: (record: PendingCallRecord) => Error;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export class PendingCallRegistry {
|
|
@@ -63,6 +67,14 @@ export class PendingCallRegistry {
|
|
|
63
67
|
catch { error = new Error("pending daemon call timed out"); }
|
|
64
68
|
reject(error instanceof Error ? error : new Error("pending daemon call timed out"));
|
|
65
69
|
}, input.timeoutMs);
|
|
70
|
+
const abortHandler = () => {
|
|
71
|
+
const record = this.take(input.id);
|
|
72
|
+
if (!record) return;
|
|
73
|
+
let error: unknown;
|
|
74
|
+
try { error = input.onAbort?.(record); }
|
|
75
|
+
catch { error = new Error("pending daemon call was cancelled"); }
|
|
76
|
+
reject(error instanceof Error ? error : new Error("pending daemon call was cancelled"));
|
|
77
|
+
};
|
|
66
78
|
const record: PendingCallRecord = {
|
|
67
79
|
id: input.id,
|
|
68
80
|
socket: input.socket,
|
|
@@ -72,9 +84,13 @@ export class PendingCallRegistry {
|
|
|
72
84
|
timeout,
|
|
73
85
|
resolve,
|
|
74
86
|
reject,
|
|
87
|
+
signal: input.signal,
|
|
88
|
+
abortHandler: input.signal ? abortHandler : undefined,
|
|
75
89
|
};
|
|
76
90
|
this.byId.set(input.id, record);
|
|
77
91
|
if (input.clientRequestKey) this.byRequestKey.set(input.clientRequestKey, input.id);
|
|
92
|
+
if (input.signal?.aborted) abortHandler();
|
|
93
|
+
else input.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
78
94
|
});
|
|
79
95
|
}
|
|
80
96
|
|
|
@@ -131,6 +147,7 @@ export class PendingCallRegistry {
|
|
|
131
147
|
const record = this.byId.get(id);
|
|
132
148
|
if (!record) return undefined;
|
|
133
149
|
clearTimeout(record.timeout);
|
|
150
|
+
if (record.signal && record.abortHandler) record.signal.removeEventListener("abort", record.abortHandler);
|
|
134
151
|
this.byId.delete(id);
|
|
135
152
|
if (record.clientRequestKey && this.byRequestKey.get(record.clientRequestKey) === id) {
|
|
136
153
|
this.byRequestKey.delete(record.clientRequestKey);
|
package/wrangler.jsonc
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "machine-bridge-mcp",
|
|
4
4
|
"main": "src/worker/index.ts",
|
|
5
5
|
"compatibility_date": "2026-07-11",
|
|
6
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
6
|
+
"compatibility_flags": ["nodejs_compat", "enable_request_signal", "request_signal_passthrough"],
|
|
7
7
|
"workers_dev": true,
|
|
8
8
|
"durable_objects": {
|
|
9
9
|
"bindings": [
|