machine-bridge-mcp 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-07-15
4
+
5
+ ### Claude and Microsoft Copilot Studio remote MCP
6
+
7
+ - Add hosted-client OAuth interoperability for Claude custom connectors and Microsoft Copilot Studio while preserving the existing Streamable HTTP transport and current MCP protocol contract. Authorization-server and protected-resource discovery now advertise `offline_access` and the refresh-token grant; dynamic client registration declares both authorization-code and refresh-token grants.
8
+ - Issue hashed access and refresh-token records from authorization-code exchange. Public-client refresh requests are form-encoded, bound to the original client/account/version/role/scope/resource, and rotate the refresh token atomically. Reuse, expiry, account changes, or deployment token-version changes return `invalid_grant`. Refresh state uses a separate versioned Durable Object key, so existing primary OAuth state does not require migration. The per-source DCR throttle now counts only registrations that have not completed authorization, preventing legitimate Claude or Copilot reconnections from consuming the abuse quota while retaining the deployment-wide client cap.
9
+ - Exercise the exact hosted Claude callback, the unauthenticated `resource_metadata` challenge, discovery/DCR metadata, refreshed MCP access, refresh replay rejection, and account-targeted refresh revocation in the real Worker integration. Document Claude and Copilot Studio setup, and retain the narrow ChatGPT/Grok browser CORS set because Claude and Copilot Studio connect through cloud-side infrastructure rather than browser-origin response sharing.
10
+
3
11
  ## 1.0.8 - 2026-07-14
4
12
 
5
13
  ### Effective account authority reporting
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  It supports two transports:
6
6
 
7
7
  ```text
8
- Remote clients such as ChatGPT
8
+ Remote clients such as ChatGPT, Claude, and Copilot Studio
9
9
  HTTPS + OAuth 2.1 / PKCE
10
10
  |
11
11
  Cloudflare Worker + Durable Object
@@ -47,7 +47,7 @@ This default prioritizes usability, not least privilege. `run_process` and proce
47
47
 
48
48
  ## Install
49
49
 
50
- For prerequisites, first remote deployment, ChatGPT connection, stdio configuration, browser pairing, upgrades, troubleshooting, and removal, follow [Installation and first use](docs/GETTING_STARTED.md). The commands below are the compact installation reference.
50
+ For prerequisites, first remote deployment, hosted-client connection, stdio configuration, browser pairing, upgrades, troubleshooting, and removal, follow [Installation and first use](docs/GETTING_STARTED.md). The commands below are the compact installation reference.
51
51
 
52
52
  Version 1 supports only MCP protocol `2025-11-25`. Existing current-schema local state upgrades in place, but clients must reconnect and the unpacked browser extension must be reloaded after package upgrade.
53
53
 
@@ -95,7 +95,7 @@ npm install
95
95
  .\mbm.cmd # Windows cmd
96
96
  ```
97
97
 
98
- ## Remote MCP for ChatGPT
98
+ ## Remote MCP for hosted clients
99
99
 
100
100
  After a global installation, `machine-mcp` can be launched from any Command Prompt or terminal; the package installation directory is not the workspace. On the first interactive Windows start, the CLI asks:
101
101
 
@@ -136,13 +136,21 @@ Use the printed URL in the MCP client:
136
136
  MCP Server URL: https://<worker>.<account>.workers.dev/mcp
137
137
  ```
138
138
 
139
- During OAuth authorization, enter the name and password of a Machine Bridge account. The first start creates `owner` and prints its generated password once. Additional accounts are managed with `machine-mcp account`. The authorization flow uses PKCE S256, exact redirect/resource binding, expiring hashed access tokens, per-account version checks, and a deployment-wide token version for emergency bulk revocation.
139
+ During OAuth authorization, enter the name and password of a Machine Bridge account. The first start creates `owner` and prints its generated password once. Additional accounts are managed with `machine-mcp account`. The authorization flow exposes protected-resource and authorization-server discovery, dynamic client registration, PKCE S256, exact redirect/resource binding, `offline_access`, expiring hashed access and refresh-token records, public-client refresh-token rotation, per-account version checks, and a deployment-wide token version for emergency bulk revocation.
140
140
 
141
- The Worker grants browser CORS response access to the exact first-party origins used by ChatGPT (`https://chatgpt.com` and the legacy `https://chat.openai.com`) and Grok (`https://grok.com` and its X-hosted surface at `https://x.com`). Users do not need to run Wrangler or edit Cloudflare variables. `MBM_ALLOWED_ORIGINS` remains an operator-only, comma-separated extension point for other exact origins; it is additive and does not replace the built-in set. Wildcards and `null` do not receive CORS permission. Actual OAuth navigations and form submissions are not rejected solely because a browser container supplies an opaque or client-specific `Origin`; authorization still depends on exact redirect/resource binding, PKCE, account authentication, and bearer-token validation. The consent page CSP permits form navigation only to the Worker itself and the exact validated redirect origin for that request, so Chromium can follow the successful `303` callback without granting arbitrary form destinations.
141
+ The same `/mcp` endpoint supports the current hosted-client paths documented by their vendors:
142
+
143
+ - ChatGPT and Grok can use the public Streamable HTTP endpoint and browser OAuth flow.
144
+ - Claude custom connectors can add the exact `/mcp` URL and use dynamic client registration. The hosted Claude callback `https://claude.ai/api/mcp/auth_callback`, `offline_access`, form-encoded token exchange, proactive/reactive refresh, and one-time refresh-token rotation are covered by the Worker integration contract.
145
+ - Microsoft Copilot Studio can use **Tools > Add a tool > New tool > Model Context Protocol**, select **OAuth 2.0 > Dynamic discovery**, and point **Server URL** at the exact `/mcp` endpoint. Machine Bridge advertises the Streamable transport, discovery metadata, DCR, access tokens, and rotating refresh tokens expected by that path.
146
+
147
+ These are protocol and integration-contract claims, not evidence that every external tenant, plan, administrator policy, or host-side tool filter has been tested. Claude remote connectors originate from Anthropic's cloud, and Copilot Studio reaches MCP servers through Power Platform connectivity; neither requires adding a browser origin to Machine Bridge's CORS list.
148
+
149
+ The Worker grants browser CORS response access to the exact first-party origins used by ChatGPT (`https://chatgpt.com` and the legacy `https://chat.openai.com`) and Grok (`https://grok.com` and its X-hosted surface at `https://x.com`). Users do not need to run Wrangler or edit Cloudflare variables. `MBM_ALLOWED_ORIGINS` remains an operator-only, comma-separated extension point for other exact browser origins; it is additive and does not replace the built-in set. Wildcards and `null` do not receive CORS permission. Actual OAuth navigations and form submissions are not rejected solely because a browser container supplies an opaque or client-specific `Origin`; authorization still depends on exact redirect/resource binding, PKCE, account authentication, and bearer-token validation. The consent page CSP permits form navigation only to the Worker itself and the exact validated redirect origin for that request, so Chromium can follow the successful `303` callback without granting arbitrary form destinations.
142
150
 
143
151
  ### Multiple clients and accounts
144
152
 
145
- One Worker supports several named accounts and OAuth clients. Accounts have independent passwords, roles, active state, versions, codes, and tokens. The roles are `reviewer`, `editor`, `operator`, and `owner`; their effective authority is intersected with the connected daemon policy and enforced in both the Worker and local runtime. Role changes, suspension, password rotation, and removal revoke only the affected account.
153
+ One Worker supports several named accounts and OAuth clients. Accounts have independent passwords, roles, active state, versions, authorization codes, access tokens, and refresh tokens. The roles are `reviewer`, `editor`, `operator`, and `owner`; their effective authority is intersected with the connected daemon policy and enforced in both the Worker and local runtime. Role changes, suspension, password rotation, and removal revoke only the affected account.
146
154
 
147
155
  For remote connections, `server_info.authorization.effective_policy` and `server_info.authorization.effective_tools` are the authoritative account-permission fields. `server_info.daemon.policy` and `server_info.daemon.tools` describe only the local daemon capability ceiling before account-role filtering. Remote `project_overview` likewise places the effective account policy/tools in `policy` and `tools`, while preserving the daemon ceiling separately as `daemonPolicy` and `daemonTools`.
148
156
 
@@ -219,7 +227,7 @@ machine-mcp browser status
219
227
 
220
228
  Load the printed unpacked-extension directory in the Chromium profile you actually use; Machine Bridge does not install it into Playwright or a separate automation profile. After every Machine Bridge upgrade, reload the unpacked extension and accept any new browser permission. `machine-mcp browser status` reports both the expected packaged extension build and the authenticated connected build/protocol, and states that Machine Bridge did not launch the browser. It cannot infer whether the extension was loaded into a daily or isolated profile; that is determined by where the user installed it. Trusted input uses the Chromium `debugger` permission only for fixed, short-lived DevTools Input commands. The badge shows `ON` only after the broker acknowledges the version/capability handshake. The local pairing token remains in owner-only state and non-cacheable loopback HTML; it is not returned through MCP. For a mass-market release, distribute the same extension as a signed browser-store build rather than asking end users to enable Developer mode. See [Local application and browser automation](docs/LOCAL_AUTOMATION.md).
221
229
 
222
- Machine Bridge can discover, refresh, rank, and load capabilities automatically. The ChatGPT/MCP host still owns tool selection and approval, so the server cannot force a host to expose or invoke a recommended skill, command, app, or browser operation. Check `server_info.observability.capability_routing` or `project_overview.capabilityRouting` to distinguish “the host never called the resolver” from “the resolver ran but found no relevant capability.”
230
+ Machine Bridge can discover, refresh, rank, and load capabilities automatically. The MCP host still owns tool selection and approval, so the server cannot force a host to expose or invoke a recommended skill, command, app, or browser operation. Check `server_info.observability.capability_routing` or `project_overview.capabilityRouting` to distinguish “the host never called the resolver” from “the resolver ran but found no relevant capability.”
223
231
 
224
232
 
225
233
  ## Runtime lifecycle and observability
@@ -472,7 +480,7 @@ The service definition contains neither credentials nor a duplicate policy. It l
472
480
  machine-mcp rotate-secrets
473
481
  ```
474
482
 
475
- Rotation stops the installed service, refuses to proceed while another foreground daemon owns the workspace lock, and rotates the account-administration secret, daemon secret, and deployment-wide OAuth token version. It invalidates every account access token and requires all clients to authorize again. Use `machine-mcp account rotate-password NAME` for targeted password rotation without affecting other accounts.
483
+ Rotation stops the installed service, refuses to proceed while another foreground daemon owns the workspace lock, and rotates the account-administration secret, daemon secret, and deployment-wide OAuth token version. It invalidates every account access and refresh token and requires all clients to authorize again. Use `machine-mcp account rotate-password NAME` for targeted password rotation without affecting other accounts.
476
484
 
477
485
  ## State and observability
478
486
 
@@ -499,7 +507,7 @@ npm audit --omit=dev --audit-level=high
499
507
  npm pack --dry-run
500
508
  ```
501
509
 
502
- `npm run check` covers privacy and release-impact gates, architecture/link invariants, generated Worker types, TypeScript, recursively discovered JavaScript syntax, semantic undefined-identifier checks, catalog-to-runtime handler parity, deterministic relay lifecycle and secure-file tests, concurrent lock/atomic-replacement/PID-reuse fixtures, fail-closed service lifecycle tests, descendant process-tree termination, local path/write/state/log/service invariants, Ed25519/RSA generation and key-pair validation, real-machine `full` sandbox acceptance, a clean npm package-manifest/mode/sensitive-artifact check, an isolated installation of the actual packed tarball whose zero-argument CLI startup must reach a controlled Worker-deployment boundary, managed-job integrity/redaction/finally/cancellation/recovery, a live stdio MCP flow, a live local OAuth/Worker/WebSocket/MCP flow, and a real Chromium negative/positive test for CSP-governed OAuth callback navigation. GitHub Actions runs the complete suite on Linux, macOS, and Windows with the pinned Node 26/npm 12 baseline.
510
+ `npm run check` covers privacy and release-impact gates, architecture/link invariants, generated Worker types, TypeScript, recursively discovered JavaScript syntax, semantic undefined-identifier checks, catalog-to-runtime handler parity, deterministic relay lifecycle and secure-file tests, concurrent lock/atomic-replacement/PID-reuse fixtures, fail-closed service lifecycle tests, descendant process-tree termination, local path/write/state/log/service invariants, Ed25519/RSA generation and key-pair validation, real-machine `full` sandbox acceptance, a clean npm package-manifest/mode/sensitive-artifact check, an isolated installation of the actual packed tarball whose zero-argument CLI startup must reach a controlled Worker-deployment boundary, managed-job integrity/redaction/finally/cancellation/recovery, a live stdio MCP flow, a live local OAuth/Worker/WebSocket/MCP flow covering discovery, Claude callback routing, rotating refresh tokens, replay rejection, and account-targeted refresh revocation, plus a real Chromium negative/positive test for CSP-governed OAuth callback navigation. GitHub Actions runs the complete suite on Linux, macOS, and Windows with the pinned Node 26/npm 12 baseline.
503
511
 
504
512
  The cross-cutting 0.12.0 review, corrected failure modes, and residual OS-level limits are recorded in [docs/AUDIT.md](docs/AUDIT.md). See also [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md), [docs/MULTI_ACCOUNT.md](docs/MULTI_ACCOUNT.md), [docs/AGENT_CONTEXT.md](docs/AGENT_CONTEXT.md), [docs/LOCAL_AUTOMATION.md](docs/LOCAL_AUTOMATION.md), [docs/MANAGED_JOBS.md](docs/MANAGED_JOBS.md), [docs/TESTING.md](docs/TESTING.md), [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/ENGINEERING.md](docs/ENGINEERING.md), [docs/PROJECT_STANDARDS.md](docs/PROJECT_STANDARDS.md), the generated [MCP tool reference](docs/TOOL_REFERENCE.md), and [SECURITY.md](SECURITY.md).
505
513
 
package/SECURITY.md CHANGED
@@ -40,7 +40,7 @@ The Worker is a remote authentication and relay boundary. The local runtime is t
40
40
 
41
41
  ### Accounts are authorization boundaries, not OS sandboxes
42
42
 
43
- Remote mode supports named accounts with independent passwords, roles, active state, versions, OAuth codes, and access tokens. The roles `reviewer`, `editor`, `operator`, and `owner` map to the local review, edit, agent, and full policy profiles. The Worker intersects the account role with the connected daemon policy, and the local runtime validates the account role again before dispatch. Account suspension, role changes, password rotation, and removal revoke only that account.
43
+ Remote mode supports named accounts with independent passwords, roles, active state, versions, OAuth authorization codes, access tokens, and rotating refresh tokens. The roles `reviewer`, `editor`, `operator`, and `owner` map to the local review, edit, agent, and full policy profiles. The Worker intersects the account role with the connected daemon policy, and the local runtime validates the account role again before dispatch. Account suspension, role changes, password rotation, and removal revoke only that account.
44
44
 
45
45
  An OAuth `client_id` still identifies client software and redirect URIs; it is not an account. One account can authorize several clients, and one client can be authorized by several accounts.
46
46
 
@@ -162,11 +162,11 @@ Runner diagnostic logs are owner-only and do not receive child stdout/stderr. Ex
162
162
 
163
163
  ## OAuth and public endpoints
164
164
 
165
- Remote mode uses authorization code flow with PKCE S256, exact redirect/resource/client binding, expiring authorization codes and access tokens, hashed token storage, per-account version checks, deployment-wide token-version revocation, and bounded dynamic client registration. Authorization codes and tokens are bound to one account ID, account version, and role. Successful consent constructs the registered callback through the URL API and returns `303 See Other`; response parameters are encoded rather than concatenated into an unchecked header string.
165
+ Remote mode uses authorization code flow with PKCE S256, exact redirect/resource/client binding, protected-resource and authorization-server discovery, bounded dynamic client registration, `offline_access`, expiring authorization codes, access tokens, and refresh tokens, hashed bearer-token storage, per-account version checks, and deployment-wide token-version revocation. Authorization codes and both token classes are bound to one client ID, account ID, account version, role, scope, and resource. Public-client refresh tokens are single-use: a successful refresh atomically stores a new access/refresh pair and removes the presented refresh token; replay and account/version invalidation return RFC 6749 `invalid_grant`. Successful consent constructs the registered callback through the URL API and returns `303 See Other`; response parameters are encoded rather than concatenated into an unchecked header string.
166
166
 
167
167
  The authorization page displays the validated client name and redirect URI. Enter an account name and password only after initiating the connection and recognizing both values.
168
168
 
169
- Password failures and registrations are limited by deployment-keyed HMAC source identity. Browser requests are same-origin unless an exact origin is listed in `MBM_ALLOWED_ORIGINS`; loopback OAuth redirect permission does not grant browser-origin access.
169
+ Password failures and pending, not-yet-authorized client registrations are limited by deployment-keyed HMAC source identity; successfully authorized DCR clients no longer consume that pending-registration quota, while the global client cap remains authoritative. Browser requests are same-origin unless an exact origin is listed in `MBM_ALLOWED_ORIGINS`; loopback OAuth redirect permission does not grant browser-origin access.
170
170
 
171
171
  Public health and metadata do not expose live workspace or daemon status. The daemon handshake omits workspace path/name/hash and process ID.
172
172
 
@@ -174,7 +174,7 @@ Public health and metadata do not expose live workspace or daemon status. The da
174
174
 
175
175
  Only one authenticated daemon is active. Candidates have a handshake deadline and cannot displace the current daemon before success. Pending calls are socket-bound, client-request-bound, concurrency-limited, size-limited, and timed out. Duplicate in-flight request IDs are rejected only within the same authenticated MCP session. Initialization returns a stateless HMAC-bound `MCP-Session-Id`; the signature binds the session nonce to the OAuth token identity, so one account can use multiple concurrent chat windows without sharing a cancellation or request-id namespace. Independent sessionless POST requests are not indexed by token and request id. A session id is an integrity/correlation token, not a second authorization credential.
176
176
 
177
- Request bodies, WebSocket messages, tool outputs, traversals, sessions, stdin writes, OAuth records, clients, codes, tokens, and failure identities are bounded. Disconnect/replacement terminates active child process trees.
177
+ Request bodies, WebSocket messages, tool outputs, traversals, sessions, stdin writes, OAuth records, clients, codes, access tokens, refresh tokens, and failure identities are bounded. Disconnect/replacement terminates active child process trees.
178
178
 
179
179
  These controls reduce accidental exhaustion and simple abuse. They do not replace Cloudflare account MFA, WAF/rate limits, usage alerts, or cost controls for an internet-facing Worker.
180
180
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "1.0.8",
4
+ "version": "1.1.0",
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.0.8"
33
+ "version_name": "1.1.0"
34
34
  }
@@ -103,14 +103,14 @@ The stdio server implements newline-delimited JSON-RPC over stdin/stdout. It neg
103
103
 
104
104
  All requests for a deployed Worker route to one named Durable Object. It owns:
105
105
 
106
- - OAuth clients, authorization codes, hashed access-token records, and throttling metadata;
106
+ - OAuth clients, authorization codes, hashed access-token records, an independently versioned hashed refresh-token store, and throttling metadata;
107
107
  - one active authenticated daemon WebSocket plus bounded candidate sockets;
108
108
  - policy/tool metadata attached to the active socket;
109
109
  - a bounded in-memory map of pending daemon calls.
110
110
 
111
111
  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.
112
112
 
113
- The OAuth store separates client registrations from named accounts. A `client_id` identifies an MCP application and redirect URIs; account records identify the authorized human or service identity. Codes and tokens bind client ID, account ID, account version, role, scope, resource, and expiration. 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).
113
+ 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).
114
114
 
115
115
  The daemon attachment deliberately omits workspace path/name/hash and process ID. Explicit authenticated tools may return workspace metadata according to local path-display policy.
116
116
 
@@ -141,16 +141,16 @@ flowchart LR
141
141
  CLI --> S[Autostart provider]
142
142
  ```
143
143
 
144
- Remote OAuth binds each code and token to a named Machine Bridge account, account version, and role, so accounts are independent application-level authorization principals. The Worker intersects that role with the connected daemon policy, and the local runtime enforces the relayed authorization again. This distinction is not operating-system tenancy: every account still reaches one daemon, workspace, browser profile, and OS user authority. Local stdio access relies on the local process and configuration boundary. A connector host can independently present a smaller tool subset to a session; this post-relay filtering is outside the protocol state visible to Machine Bridge. Canonical resolution limits direct filesystem tools. Processes retain local-user authority and can escape workspace constraints through their own code or system calls.
144
+ Remote OAuth binds each code, access token, and refresh token to a named Machine Bridge account, account version, and role, so accounts are independent application-level authorization principals. The Worker intersects that role with the connected daemon policy, and the local runtime enforces the relayed authorization again. This distinction is not operating-system tenancy: every account still reaches one daemon, workspace, browser profile, and OS user authority. Local stdio access relies on the local process and configuration boundary. A connector host can independently present a smaller tool subset to a session; this post-relay filtering is outside the protocol state visible to Machine Bridge. Canonical resolution limits direct filesystem tools. Processes retain local-user authority and can escape workspace constraints through their own code or system calls.
145
145
 
146
146
  ## Remote request lifecycle
147
147
 
148
148
  1. The MCP client discovers protected-resource and authorization-server metadata.
149
- 2. It dynamically registers bounded redirect metadata.
149
+ 2. It dynamically registers bounded redirect metadata. Per-source throttling counts only registrations that have not yet completed authorization, while a separate global cap bounds all retained clients.
150
150
  3. The Worker validates authorization parameters before displaying a password form.
151
151
  4. The user verifies client name and redirect URI and enters a Machine Bridge account name and password.
152
- 5. The Worker creates a five-minute code bound to client, redirect, resource, scope, and PKCE challenge.
153
- 6. A valid verifier exchanges the one-time code for an expiring bearer token; only its hash is stored.
152
+ 5. The Worker creates a five-minute code bound to client, redirect, resource, normalized scope, and PKCE challenge.
153
+ 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`.
154
154
  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.
155
155
  8. `tools/list` is derived from the active daemon handshake; without a daemon, only `server_info` is advertised.
156
156
  9. `tools/call` receives a random relay call ID and is bound to the current socket and authenticated client request key.
@@ -233,9 +233,9 @@ Active managed jobs persist an owner-only plan, status, runner process identity,
233
233
 
234
234
  Removal first acquires a state-root maintenance lock that blocks new profile/state claims and state-backed operations from already constructed managed-job/browser managers, then stops the platform service and all known verified workspace daemons. It then validates the state marker, canonical target, known contents, active or unreadable locks, filesystem root/home/current/package/workspace/source exclusions, managed jobs, and Worker deletion outcome before recursive deletion. Any unresolved phase retains definitions and state.
235
235
 
236
- OAuth metadata is pruned on access. Expired codes/tokens, old throttling records, and inactive clients without active credentials are removed. Source identities are deployment-keyed HMAC values, not stored source addresses or reversible unsalted hashes.
236
+ OAuth metadata is pruned on access. Expired authorization codes, access tokens, refresh tokens, old throttling records, and inactive clients without active credentials are removed. Account disablement, role or password changes, removal, and deployment-wide token-version rotation make both token classes unusable; stale refresh records are deleted when the refresh store is next read. Source identities are deployment-keyed HMAC values, not stored source addresses or reversible unsalted hashes.
237
237
 
238
- Browser-origin handling separates CORS response sharing from protocol authentication. Preflight succeeds only for the Worker's own origin, a fixed first-party set for ChatGPT (`https://chatgpt.com`, `https://chat.openai.com`) and Grok (`https://grok.com`, `https://x.com`), or optional exact comma-separated additions from `MBM_ALLOWED_ORIGINS`; wildcard and `null` origins are not granted CORS access. Actual requests are routed without using `Origin` as an authentication boundary, and `Access-Control-Allow-Origin` is added only when that exact predicate passes. This permits OAuth top-level navigation and form submission from opaque or client-specific containers while exact redirect/resource binding, PKCE, account credentials, bearer tokens, and private admin/daemon secrets enforce authority. The authorization document's `form-action` policy is generated only after request validation and contains `'self'` plus the exact origin of that validated redirect URI. This allows Chromium to follow the successful form submission's `303` callback without opening form navigation to unrelated origins.
238
+ Browser-origin handling separates CORS response sharing from protocol authentication. Preflight succeeds only for the Worker's own origin, a fixed first-party set for ChatGPT (`https://chatgpt.com`, `https://chat.openai.com`) and Grok (`https://grok.com`, `https://x.com`), or optional exact comma-separated additions from `MBM_ALLOWED_ORIGINS`; wildcard and `null` origins are not granted CORS access. Actual requests are routed without using `Origin` as an authentication boundary, and `Access-Control-Allow-Origin` is added only when that exact predicate passes. This permits OAuth top-level navigation and form submission from opaque or client-specific containers while exact redirect/resource binding, PKCE, account credentials, bearer tokens, and private admin/daemon secrets enforce authority. The authorization document's `form-action` policy is generated only after request validation and contains `'self'` plus the exact origin of that validated redirect URI. This allows Chromium to follow the successful form submission's `303` callback without opening form navigation to unrelated origins. Hosted Claude remote connectors originate from Anthropic infrastructure and Copilot Studio uses Power Platform connectivity, so their server-to-server requests do not require browser CORS entries; adding those brands to the browser allowlist would expand response sharing without enabling either integration.
239
239
 
240
240
  ## Observability
241
241
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Engineering and security audit
2
2
 
3
+ ## 2026-07-15 hosted-client OAuth interoperability audit
4
+
5
+ The Claude and Microsoft Copilot Studio request was first tested against the existing protocol boundary rather than implemented as a brand allowlist. Both clients already use the deployed Streamable HTTP endpoint; the material gap was token continuity. Anthropic's connector contract advertises `offline_access`, performs form-encoded refreshes, and requires public-client refresh-token rotation. Copilot Studio's recommended Dynamic discovery path likewise discovers DCR/OAuth endpoints and exchanges refresh tokens. Adding browser origins alone would not implement either contract: Claude remote connectors originate from Anthropic cloud infrastructure, while Copilot Studio uses Power Platform connectivity. The fixed ChatGPT/Grok browser CORS set therefore remains unchanged.
6
+
7
+ The Worker now advertises the refresh grant and `offline_access`, stores only hashes of access and refresh tokens, and rotates every refresh token in one Durable Object write with the associated access-token update. Refresh records live under a separate versioned storage key, preserving the current primary OAuth-store schema and avoiding a destructive live migration. Refreshes remain bound to client, account, account version, role, normalized scope, resource, and deployment token version. Old-token replay and account-version changes return `invalid_grant`; account suspension, role/password changes, removal, and global token-version rotation cannot extend authority through refresh. Because both hosted clients may create a new DCR client for a fresh connection, the source throttle now counts only registrations that have not completed authorization; the global retained-client bound is unchanged.
8
+
9
+ The workerd integration proves the hosted Claude callback, the unauthenticated `resource_metadata` challenge, discovery metadata, DCR grant advertisement, `offline_access`, form-encoded refresh, access/refresh replacement, replay rejection, continued MCP use with the refreshed access token, and targeted account refresh revocation. Copilot Studio compatibility is established at the documented protocol boundary rather than claimed as a live tenant test; Power Platform data policies, plan availability, tenant administration, and host-side tool filtering remain external controls.
10
+
3
11
  This document records the cross-cutting audit initiated for version 0.12.0, its later architecture, browser, lifecycle, authorization, release-gate, and version 1 follow-ups, including the version 1.0.2 elapsed-time review and the version 1.0.3 code-scanning inventory correction. It complements, but does not replace, the continuously enforced contracts in `SECURITY.md`, `docs/ENGINEERING.md`, and the test suite.
4
12
 
5
13
 
package/docs/CLIENTS.md CHANGED
@@ -7,7 +7,7 @@ An MCP server is not a model provider. It exposes tools, resources, or prompts.
7
7
  For this project:
8
8
 
9
9
  ```text
10
- Claude Desktop / Cursor / Codex / ChatGPT Desktop / another MCP host
10
+ Claude Desktop / Cursor / Codex / ChatGPT Desktop / another local MCP host
11
11
  owns the model, account, conversation, approvals, and tool-selection loop
12
12
  |
13
13
  | stdio or Streamable HTTP
@@ -32,7 +32,7 @@ For many users, the client's native filesystem, patch, terminal, and approval sy
32
32
  The stdio mode exists for narrower cases:
33
33
 
34
34
  1. **One tool contract across several clients.** The same tool names, schemas, patch behavior, Git handling, process sessions, result formats, and logging work in Claude Desktop, Cursor, Codex, ChatGPT Desktop, and other MCP hosts.
35
- 2. **Transport parity.** The local client can use essentially the same Machine Bridge runtime that remote ChatGPT access uses, without maintaining a second implementation.
35
+ 2. **Transport parity.** The local client can use essentially the same Machine Bridge runtime that hosted ChatGPT, Claude, Grok, and Copilot Studio connections use, without maintaining a second implementation.
36
36
  3. **No remote relay for same-machine use.** The host launches the server directly, so no Cloudflare Worker, public URL, OAuth flow, or network round trip is needed.
37
37
  4. **Capabilities missing from a particular host.** Exact edit semantics, transactional multi-file patches, retained process sessions, image results, or the project's policy profiles may be useful even when the host has basic shell tools.
38
38
  5. **Durable managed jobs.** The same detached job/resource/finally semantics are available to local and remote hosts, while local CLI remains an operator fallback.
@@ -44,7 +44,7 @@ It is therefore an optional compatibility and reuse surface, not a replacement f
44
44
 
45
45
  | Situation | Recommended path |
46
46
  |---|---|
47
- | ChatGPT web or another service must reach this computer remotely | Remote Worker with HTTPS/OAuth |
47
+ | ChatGPT, Claude, Grok, Copilot Studio, or another hosted service must reach this computer remotely | Remote Worker with Streamable HTTPS/OAuth |
48
48
  | A local MCP host needs Machine Bridge-specific tools | stdio |
49
49
  | The local coding client already provides everything needed | Use the client's native tools; do not configure Machine Bridge stdio |
50
50
  | Several local clients should share identical tool semantics | stdio |
@@ -56,7 +56,7 @@ The MCP specification defines stdio and Streamable HTTP as standard transports.
56
56
 
57
57
  MCP initialization and `resolve_task_capabilities` give the host a current view of conservative built-in working agreements, bounded project facts, explicit global/project instructions, skills, explicit/automatic package commands, applications, and browser capability. The resolver rescans rather than relying on a stale dynamic tool list and can return the best matching skill instructions in one call. No instruction file is required for the default layers, and no repository file is written automatically.
58
58
 
59
- The host still owns the agent loop. ChatGPT web may use the recommendation automatically, ask for confirmation, expose only part of the catalog, or ignore server instructions. No MCP implementation can guarantee automatic invocation from the server side. Machine Bridge models that limitation explicitly instead of treating a recommendation as execution.
59
+ The host still owns the agent loop. A hosted client may use the recommendation automatically, ask for confirmation, expose only part of the catalog, or ignore server instructions. No MCP implementation can guarantee automatic invocation from the server side. Machine Bridge models that limitation explicitly instead of treating a recommendation as execution.
60
60
 
61
61
  For browser tasks, the remote host reaches the local extension through the Worker and daemon. The extension controls the existing Chromium profile; it is not a separate Playwright profile.
62
62
 
@@ -114,7 +114,7 @@ args = ["/absolute/path/to/machine-mcp.mjs", "stdio", "--workspace", "/path/to/p
114
114
 
115
115
  Codex CLI, the Codex IDE extension, and supported ChatGPT Desktop Codex hosts can share MCP configuration on the same Codex host. ChatGPT web does not read local Codex configuration files; web use requires a hosted plugin/connector or a reachable remote MCP endpoint.
116
116
 
117
- ## Remote ChatGPT and Grok connection
117
+ ## Remote hosted-client connection
118
118
 
119
119
  Run:
120
120
 
@@ -122,9 +122,17 @@ Run:
122
122
  machine-mcp --workspace /path/to/project
123
123
  ```
124
124
 
125
- Enter the printed `/mcp` URL in the remote MCP connector. During OAuth authorization, verify the displayed client name and redirect URI before entering a Machine Bridge account name and password. The Worker has exact built-in CORS response support for `https://chatgpt.com`, legacy `https://chat.openai.com`, `https://grok.com`, and the X-hosted Grok surface at `https://x.com`; no direct Wrangler command or Cloudflare variable edit is required. OAuth navigations and form submissions from opaque or client-specific browser containers are routed normally but receive no cross-origin response-sharing permission unless their exact origin is allowed. The authorization page also permits form navigation only to itself and the exact validated redirect origin, which is required for Chromium to follow the successful `303` back to the client.
125
+ Enter the printed `/mcp` URL in the remote MCP connector. During OAuth authorization, verify the displayed client name and redirect URI before entering a Machine Bridge account name and password. The Worker exposes Streamable HTTP plus protected-resource discovery, authorization-server discovery, dynamic client registration, PKCE S256, `offline_access`, and rotating refresh tokens.
126
126
 
127
- Several OAuth clients and named accounts can coexist. Accounts have independent passwords, roles, active state, versions, and targeted revocation. Their effective tool sets are intersected with the connected daemon policy. All accounts still share one daemon and OS user, so hard tenant isolation requires separate deployments; see [MULTI_ACCOUNT.md](MULTI_ACCOUNT.md).
127
+ Supported configuration paths are:
128
+
129
+ - **ChatGPT and Grok:** enter the `/mcp` URL in the corresponding remote connector. The Worker has exact built-in CORS response support for `https://chatgpt.com`, legacy `https://chat.openai.com`, `https://grok.com`, and the X-hosted Grok surface at `https://x.com`.
130
+ - **Claude custom connectors:** add the exact `/mcp` URL under Claude's connector settings and leave static OAuth client credentials empty so Claude can use DCR. Hosted Claude surfaces use `https://claude.ai/api/mcp/auth_callback`; the Worker accepts that exact registered redirect and rotates refresh tokens for the public client. See Anthropic's [remote custom connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) and [authentication contract](https://claude.com/docs/connectors/building/authentication).
131
+ - **Microsoft Copilot Studio:** from the agent's **Tools** page, select **Add a tool > New tool > Model Context Protocol**, enter the `/mcp` URL, then select **OAuth 2.0 > Dynamic discovery**. Create the connection and add it to the agent. See Microsoft's [existing MCP server guide](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent).
132
+
133
+ OAuth navigations and form submissions from opaque or client-specific browser containers are routed normally but receive no cross-origin response-sharing permission unless their exact origin is allowed. The authorization page permits form navigation only to itself and the exact validated redirect origin. Claude remote connectors originate from Anthropic infrastructure, while Copilot Studio connectivity runs through Power Platform connectors, so neither is a reason to broaden the browser CORS allowlist.
134
+
135
+ Several OAuth clients and named accounts can coexist. Accounts have independent passwords, roles, active state, versions, authorization codes, access tokens, refresh tokens, and targeted revocation. Their effective tool sets are intersected with the connected daemon policy. All accounts still share one daemon and OS user, so hard tenant isolation requires separate deployments; see [MULTI_ACCOUNT.md](MULTI_ACCOUNT.md).
128
136
 
129
137
  ## Profile guidance
130
138
 
@@ -1,6 +1,6 @@
1
1
  # Installation and first-use guide
2
2
 
3
- This guide covers a clean installation, the first remote ChatGPT connection, local stdio clients, browser setup, routine operation, upgrades, troubleshooting, and removal.
3
+ This guide covers a clean installation, first remote connections from ChatGPT, Claude, Grok, and Microsoft Copilot Studio, local stdio clients, browser setup, routine operation, upgrades, troubleshooting, and removal.
4
4
 
5
5
  ## 1. Decide which connection mode you need
6
6
 
@@ -8,7 +8,7 @@ Machine Bridge has two transports. Choose one before installing:
8
8
 
9
9
  | Requirement | Use | Cloudflare required | Process that must remain available |
10
10
  |---|---|---:|---|
11
- | ChatGPT web, a hosted agent, or another device must reach this computer | Remote HTTPS/OAuth | Yes | Machine Bridge daemon or installed login service |
11
+ | ChatGPT, Claude, Grok, Copilot Studio, a hosted agent, or another device must reach this computer | Remote Streamable HTTPS/OAuth | Yes | Machine Bridge daemon or installed login service |
12
12
  | Claude Desktop, Cursor, Codex, ChatGPT Desktop, or another local host runs on this computer | Local stdio | No | The MCP host launches Machine Bridge as a subprocess |
13
13
  | The local coding client already has equivalent file, Git, patch, and terminal tools | Neither | No | None |
14
14
 
@@ -181,7 +181,7 @@ To avoid installing autostart during a temporary test:
181
181
  machine-mcp --workspace /path/to/project --no-autostart
182
182
  ```
183
183
 
184
- ## 7. Connect ChatGPT
184
+ ## 7. Connect a hosted MCP client
185
185
 
186
186
  Machine Bridge prints the remote endpoint and, only when creating the first account, a one-time owner password:
187
187
 
@@ -204,7 +204,35 @@ Current ChatGPT developer-mode flow, as documented in OpenAI's [Connect from Cha
204
204
  7. Enter the Machine Bridge account name and password only after those values are recognized.
205
205
  8. Create a new chat and enable the app for that conversation.
206
206
 
207
- ChatGPT navigation labels can change. The invariant is that the client must connect to the public `/mcp` endpoint and complete the OAuth authorization page served by the Worker. The same public `/mcp` URL can be entered in Grok where remote MCP configuration is available. Machine Bridge grants CORS response access to the exact `https://grok.com` and `https://x.com` browser origins as well as ChatGPT's current and legacy origins without requiring a Wrangler command. OAuth navigation and form POST requests are not failed solely because an embedded authorization container reports an opaque or different origin. After validation, the consent page CSP includes only the exact registered callback origin so the browser can complete the `303` return to ChatGPT or Grok.
207
+ ChatGPT navigation labels can change. The invariant is that the client must connect to the public `/mcp` endpoint and complete the OAuth authorization page served by the Worker. The same public `/mcp` URL can be entered in Grok where remote MCP configuration is available. Machine Bridge grants CORS response access to the exact `https://grok.com` and `https://x.com` browser origins as well as ChatGPT's current and legacy origins without requiring a Wrangler command. OAuth navigation and form POST requests are not failed solely because an embedded authorization container reports an opaque or different origin. After validation, the consent page CSP includes only the exact registered callback origin so the browser can complete the `303` return.
208
+
209
+ ### Claude custom connector
210
+
211
+ For an individual Pro or Max account, Anthropic currently documents this flow:
212
+
213
+ 1. Open **Customize > Connectors**.
214
+ 2. Select **+ > Add custom connector**.
215
+ 3. Enter the exact printed `/mcp` URL.
216
+ 4. Leave optional static OAuth client credentials empty; Machine Bridge supports dynamic client registration.
217
+ 5. Add the connector, select **Connect**, and complete the Machine Bridge authorization page.
218
+ 6. Enable the connector for a conversation from the chat's connector menu.
219
+
220
+ For Team and Enterprise organizations, an Owner first adds **Custom > Web** under **Organization settings > Connectors**, after which members connect individually. Hosted Claude surfaces register `https://claude.ai/api/mcp/auth_callback`, request `offline_access` when advertised, and refresh tokens before or after access-token expiry. Machine Bridge accepts that callback and rotates each public-client refresh token exactly once. See Anthropic's [custom connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) and [connector authentication requirements](https://claude.com/docs/connectors/building/authentication).
221
+
222
+ ### Microsoft Copilot Studio
223
+
224
+ Microsoft currently recommends the MCP onboarding wizard:
225
+
226
+ 1. Open the agent's **Tools** page.
227
+ 2. Select **Add a tool > New tool > Model Context Protocol**.
228
+ 3. Enter a server name, description, and the exact printed `/mcp` URL.
229
+ 4. Select **OAuth 2.0**, then **Dynamic discovery**.
230
+ 5. Select **Create**, then **Next**.
231
+ 6. Create or select a connection and choose **Add to agent**.
232
+
233
+ Copilot Studio supports Streamable transport rather than the obsolete SSE transport. Its dynamic-discovery path uses the Worker's protected-resource metadata, authorization-server metadata, DCR endpoint, authorization-code exchange, and refresh-token exchange. See Microsoft's [existing MCP server guide](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent). Power Platform data policies and tenant administration can still block a connection independently of Machine Bridge.
234
+
235
+ Claude remote connectors originate from Anthropic's cloud, and Copilot Studio reaches the endpoint through Power Platform connectors. Those server-to-server paths do not require adding Claude or Microsoft browser domains to `MBM_ALLOWED_ORIGINS`; widening CORS would not solve tenant, firewall, or Power Platform policy failures.
208
236
 
209
237
  ## 8. Verify the first connection
210
238
 
@@ -368,7 +396,9 @@ A connected Worker with no authenticated daemon can authorize OAuth successfully
368
396
 
369
397
  ### MCP-host layer
370
398
 
371
- If `server_info` or `project_overview` never returns a structured Machine Bridge result, the host may not have delivered the call. Check the host's plugin/app enablement, tool permissions, approval UI, and connection status.
399
+ If `server_info` or `project_overview` never returns a structured Machine Bridge result, the host may not have delivered the call. Check the host's plugin/app enablement, tool permissions, approval UI, connection status, tenant policy, and whether its configured server URL exactly ends in `/mcp`.
400
+
401
+ An OAuth refresh response of `invalid_grant` is intentional after refresh-token rotation, account suspension, role change, password rotation, removal, or deployment-wide token-version rotation. Remove and reconnect the hosted connector rather than retrying the stale refresh token.
372
402
 
373
403
  ### Operating-system layer
374
404
 
package/docs/LOGGING.md CHANGED
@@ -79,7 +79,7 @@ The implementation omits:
79
79
  - stdin, stdout, and stderr;
80
80
  - file, patch, image, and temporary-file content;
81
81
  - OAuth request bodies;
82
- - account passwords, account-administration secrets, daemon secrets, authorization codes, and access tokens;
82
+ - account passwords, account-administration secrets, daemon secrets, authorization codes, access tokens, and refresh tokens;
83
83
  - registered resource values and source paths;
84
84
  - browser pairing tokens, page URLs/source, DOM metadata, form values, uploaded file bytes, and screenshots;
85
85
  - application names, Accessibility trees, selectors, and entered values;
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## What isolation means
4
4
 
5
- Machine Bridge remote mode supports several named accounts on one workspace Worker. Each account has an independent password, role, active state, version, OAuth authorization codes, and access tokens. Account changes revoke only that account's outstanding credentials.
5
+ Machine Bridge remote mode supports several named accounts on one workspace Worker. Each account has an independent password, role, active state, version, OAuth authorization codes, access tokens, and refresh tokens. Account changes revoke only that account's outstanding credentials.
6
6
 
7
7
  This is application-level authorization, not operating-system isolation. All accounts ultimately reach one local daemon running as one OS user. Roles limit which Machine Bridge tools can be listed and invoked; they do not create separate filesystems, browser profiles, process namespaces, keychains, network identities, or kernel security boundaries.
8
8
 
@@ -198,6 +198,12 @@ Uninstall refuses to remove local state while any managed job remains active. Ac
198
198
 
199
199
  See [MANAGED_JOBS.md](MANAGED_JOBS.md).
200
200
 
201
+ ## Hosted OAuth reconnection
202
+
203
+ Hosted clients may refresh access without asking the user to authorize again. Machine Bridge returns a replacement refresh token on every successful public-client refresh and invalidates the presented token. `invalid_grant` therefore means the token was already rotated, expired, or revoked by an account change or deployment-wide token-version rotation. Remove and reconnect the hosted connector; repeatedly retrying the stale token cannot recover it.
204
+
205
+ Claude and Copilot Studio call the public Worker from their cloud connectivity layers. Do not add broad Anthropic or Microsoft domains to `MBM_ALLOWED_ORIGINS` as a connectivity workaround; that variable controls browser response sharing, not server-to-server reachability, tenant policy, WAF rules, or Power Platform data policy.
206
+
201
207
  ## Reconnect and replacement
202
208
 
203
209
  The daemon sends heartbeats and reconnects with bounded exponential backoff and jitter. A new socket remains a candidate until it authenticates and sends a valid `hello`; only then does it replace the previous daemon.
package/docs/TESTING.md CHANGED
@@ -53,7 +53,7 @@ The suite includes:
53
53
  - deterministic property tests over hostile browser-protocol byte strings, canonical/custom policy combinations, argv bounds/NULs, and a real direct process proving shell metacharacters remain literal argv;
54
54
  - CLI parsing, policy profiles, and client configuration boundaries;
55
55
  - live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
56
- - live local Worker OAuth registration, consent, URL-constructed `303` callbacks including the ChatGPT redirect URI and encoded state, PKCE, token replay rejection, throttling, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
56
+ - live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, access/refresh rotation, stale refresh replay rejection, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
57
57
  - local runtime proof that one blocked tool handler does not serialize an independent handler, plus relay fault injection proving an undeliverable terminal result interrupts the ambiguous socket and enters reconnect backoff.
58
58
  - a real headless-Chrome OAuth navigation regression with a negative control showing that `form-action 'self'` blocks a cross-origin `303`, followed by the production policy shape proving the exact callback origin receives `code` and `state`. Linux CI fails if Chrome is unavailable; other environments skip only this browser executable check while retaining the Worker CSP assertions.
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
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",
@@ -9,10 +9,11 @@ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./
9
9
  import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
10
10
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority";
11
11
  import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin";
12
+ import { exchangeOAuthToken } from "./oauth-tokens";
12
13
  import { serverInfoTool, workspaceTools } from "./tool-catalog";
13
14
  import {
14
- AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore, isCurrentOAuthStore,
15
- pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
15
+ AUTH_BLOCK_SECONDS, OFFLINE_ACCESS_SCOPE, accountByName, authorizationIdentity, emptyOAuthStore,
16
+ isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
16
17
  recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
17
18
  type OAuthClient, type OAuthStore, type ValidatedAuthorization,
18
19
  } from "./oauth-state";
@@ -23,13 +24,12 @@ import {
23
24
  } from "./http";
24
25
 
25
26
  const SERVER_NAME = String(serverMetadata.name);
26
- const SERVER_VERSION = "1.0.8";
27
+ const SERVER_VERSION = "1.1.0";
27
28
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
28
29
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
29
30
  const JSONRPC_VERSION = "2.0";
30
31
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
31
32
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
32
- const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
33
33
  const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
34
34
  const MAX_PENDING_CALLS = 32;
35
35
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -38,10 +38,8 @@ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
38
38
  const MAX_OAUTH_CLIENTS = 50;
39
39
  const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
40
40
  const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
41
- const MAX_TOKENS_PER_CLIENT = 20;
42
41
  const MAX_CODES_PER_CLIENT = 10;
43
42
  const MAX_OAUTH_CODES = 200;
44
- const MAX_OAUTH_TOKENS = 500;
45
43
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
46
44
  const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
47
45
 
@@ -591,10 +589,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
591
589
  token_endpoint: `${base}/oauth/token`,
592
590
  registration_endpoint: `${base}/oauth/register`,
593
591
  response_types_supported: ["code"],
594
- grant_types_supported: ["authorization_code"],
592
+ grant_types_supported: ["authorization_code", "refresh_token"],
595
593
  token_endpoint_auth_methods_supported: ["none"],
596
594
  code_challenge_methods_supported: ["S256"],
597
- scopes_supported: [SERVER_NAME],
595
+ scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
598
596
  };
599
597
  }
600
598
 
@@ -602,7 +600,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
602
600
  return {
603
601
  resource: `${base}/mcp`,
604
602
  authorization_servers: [base],
605
- scopes_supported: [SERVER_NAME],
603
+ scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
606
604
  bearer_methods_supported: ["header"],
607
605
  resource_name: SERVER_NAME,
608
606
  };
@@ -646,7 +644,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
646
644
  delete client.registration_identity;
647
645
  changed = true;
648
646
  }
649
- const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
647
+ const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
650
648
  if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
651
649
  delete store.clients[clientId];
652
650
  changed = true;
@@ -689,9 +687,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
689
687
  return this.withOAuthLock(async () => {
690
688
  const store = await this.oauthStore();
691
689
  const registrationIdentity = await authorizationIdentity(request, this.identityKey());
692
- const identityClientCount = Object.values(store.clients).filter((client) => client.registration_identity === registrationIdentity).length;
693
- if (identityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
694
- return json({ error: "too_many_requests", error_description: "client registration limit reached for this source" }, 429);
690
+ const pendingIdentityClientCount = Object.values(store.clients).filter((client) => (
691
+ client.registration_identity === registrationIdentity && client.has_been_authorized === false
692
+ )).length;
693
+ if (pendingIdentityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
694
+ return json({ error: "too_many_requests", error_description: "pending client registration limit reached for this source" }, 429);
695
695
  }
696
696
  if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
697
697
  return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
@@ -703,6 +703,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
703
703
  redirect_uris: normalized,
704
704
  created_at: now,
705
705
  last_used_at: now,
706
+ has_been_authorized: false,
706
707
  registration_identity: registrationIdentity,
707
708
  };
708
709
  store.clients[client.client_id] = client;
@@ -711,7 +712,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
711
712
  client_id: client.client_id,
712
713
  client_name: client.client_name,
713
714
  redirect_uris: client.redirect_uris,
714
- grant_types: ["authorization_code"],
715
+ grant_types: ["authorization_code", "refresh_token"],
715
716
  response_types: ["code"],
716
717
  token_endpoint_auth_method: "none",
717
718
  client_id_issued_at: client.created_at,
@@ -807,6 +808,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
807
808
  }
808
809
  delete store.auth_failures[identity];
809
810
  client.last_used_at = now;
811
+ client.has_been_authorized = true;
810
812
 
811
813
  const code = randomToken("mcp_code");
812
814
  const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
@@ -829,45 +831,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
829
831
  });
830
832
  }
831
833
 
832
- private async exchangeToken(request: Request, base: string): Promise<Response> {
833
- const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
834
- if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
835
-
836
- const code = String(body.code ?? "");
837
- const verifier = String(body.code_verifier ?? "");
838
- if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
839
- return this.withOAuthLock(async () => {
840
- const store = await this.oauthStore();
841
- const record = store.codes[code];
842
- if (!record) return json({ error: "invalid_grant" }, 400);
843
- if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
844
- return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
845
- }
846
- if (String(body.resource ?? record.resource) !== record.resource) {
847
- return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
848
- }
849
- if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
850
- return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
851
- }
852
-
853
- const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
854
- if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
855
- delete store.codes[code];
856
- const accessToken = randomToken("mcp_at");
857
- store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
858
- client_id: record.client_id,
859
- account_id: record.account_id,
860
- account_version: record.account_version,
861
- role: record.role,
862
- scope: record.scope,
863
- resource: `${base}/mcp`,
864
- version: tokenVersion,
865
- expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
866
- };
867
- pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
868
- pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
869
- await this.ctx.storage.put("oauth", store);
870
- return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
834
+ private exchangeToken(request: Request, base: string): Promise<Response> {
835
+ return exchangeOAuthToken(request, base, {
836
+ storage: this.ctx.storage,
837
+ tokenVersion: this.env.OAUTH_TOKEN_VERSION ?? "",
838
+ serverName: SERVER_NAME,
839
+ loadOAuthStore: () => this.oauthStore(),
840
+ withLock: (callback) => this.withOAuthLock(callback),
871
841
  });
872
842
  }
873
843
 
@@ -1,6 +1,8 @@
1
1
  import { normalizeAccountRole, type AccountRole } from "./access";
2
2
 
3
3
  const OAUTH_STORE_SCHEMA_VERSION = 1;
4
+ const OAUTH_REFRESH_STORE_SCHEMA_VERSION = 1;
5
+ export const OFFLINE_ACCESS_SCOPE = "offline_access";
4
6
  const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
5
7
 
6
8
  export interface AccountRecord {
@@ -22,6 +24,7 @@ export interface OAuthClient {
22
24
  redirect_uris: string[];
23
25
  created_at: number;
24
26
  last_used_at: number;
27
+ has_been_authorized?: boolean;
25
28
  registration_identity?: string;
26
29
  }
27
30
 
@@ -48,6 +51,13 @@ export interface OAuthToken {
48
51
  expires_at: number;
49
52
  }
50
53
 
54
+ export type OAuthRefreshToken = OAuthToken;
55
+
56
+ export interface OAuthRefreshStore {
57
+ schema_version: number;
58
+ tokens: Record<string, OAuthRefreshToken>;
59
+ }
60
+
51
61
  export interface OAuthFailure {
52
62
  count: number;
53
63
  window_started: number;
@@ -89,6 +99,13 @@ export function emptyOAuthStore(): OAuthStore {
89
99
  };
90
100
  }
91
101
 
102
+ export function emptyOAuthRefreshStore(): OAuthRefreshStore {
103
+ return {
104
+ schema_version: OAUTH_REFRESH_STORE_SCHEMA_VERSION,
105
+ tokens: {},
106
+ };
107
+ }
108
+
92
109
  export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
93
110
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
94
111
  const store = value as Partial<OAuthStore>;
@@ -100,6 +117,24 @@ export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
100
117
  && isRecord(store.auth_failures);
101
118
  }
102
119
 
120
+ export function isCurrentOAuthRefreshStore(value: unknown): value is OAuthRefreshStore {
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
122
+ const store = value as Partial<OAuthRefreshStore>;
123
+ return store.schema_version === OAUTH_REFRESH_STORE_SCHEMA_VERSION && isRecord(store.tokens);
124
+ }
125
+
126
+ export function normalizeOAuthScope(value: unknown, serverName: string): string | null {
127
+ const raw = value === undefined ? serverName : String(value).trim();
128
+ if (!raw) return null;
129
+ const requested = raw.split(/\s+/);
130
+ const scopes = new Set(requested);
131
+ if (scopes.size !== requested.length || !scopes.has(serverName)) return null;
132
+ for (const scope of scopes) {
133
+ if (scope !== serverName && scope !== OFFLINE_ACCESS_SCOPE) return null;
134
+ }
135
+ return scopes.has(OFFLINE_ACCESS_SCOPE) ? `${serverName} ${OFFLINE_ACCESS_SCOPE}` : serverName;
136
+ }
137
+
103
138
  export function validateAuthorizationRequest(
104
139
  body: Record<string, unknown>,
105
140
  base: string,
@@ -112,12 +147,12 @@ export function validateAuthorizationRequest(
112
147
  const codeChallenge = String(body.code_challenge ?? "");
113
148
  const codeChallengeMethod = String(body.code_challenge_method ?? "");
114
149
  const requestedResource = String(body.resource ?? `${base}/mcp`);
115
- const scope = String(body.scope ?? serverName).trim();
150
+ const scope = normalizeOAuthScope(body.scope, serverName);
116
151
  const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
117
152
 
118
153
  if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
119
154
  if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
120
- if (scope !== serverName) return { error: "unsupported scope.", status: 400 };
155
+ if (!scope) return { error: "unsupported scope.", status: 400 };
121
156
  if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
122
157
  if (state.length > 1024) return { error: "state is too long.", status: 400 };
123
158
  if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
@@ -0,0 +1,208 @@
1
+ import {
2
+ emptyOAuthRefreshStore, isCurrentOAuthRefreshStore, normalizeOAuthScope, pkceS256,
3
+ pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken, safeEqual, sha256Hex,
4
+ type OAuthCode, type OAuthRefreshStore, type OAuthRefreshToken, type OAuthStore,
5
+ } from "./oauth-state";
6
+ import { HttpError, json, parseRequestBody } from "./http";
7
+
8
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
9
+ const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
10
+ const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
11
+ const MAX_ACCESS_TOKENS = 500;
12
+ const MAX_ACCESS_TOKENS_PER_CLIENT = 20;
13
+ const MAX_REFRESH_TOKENS = 500;
14
+ const MAX_REFRESH_TOKENS_PER_CLIENT = 20;
15
+ const OAUTH_REFRESH_STORE_KEY = "oauth-refresh";
16
+
17
+ type OAuthLock = <T>(callback: () => Promise<T>) => Promise<T>;
18
+
19
+ interface OAuthTokenExchangeOptions {
20
+ storage: DurableObjectStorage;
21
+ tokenVersion: string;
22
+ serverName: string;
23
+ loadOAuthStore: () => Promise<OAuthStore>;
24
+ withLock: OAuthLock;
25
+ }
26
+
27
+ interface IssuedTokenPair {
28
+ accessToken: string;
29
+ refreshToken: string;
30
+ }
31
+
32
+ export async function exchangeOAuthToken(
33
+ request: Request,
34
+ base: string,
35
+ options: OAuthTokenExchangeOptions,
36
+ ): Promise<Response> {
37
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
38
+ const grantType = String(body.grant_type ?? "");
39
+ if (grantType === "authorization_code") return exchangeAuthorizationCode(body, base, options);
40
+ if (grantType === "refresh_token") return exchangeRefreshToken(body, base, options);
41
+ return json({ error: "unsupported_grant_type" }, 400);
42
+ }
43
+
44
+ async function exchangeAuthorizationCode(
45
+ body: Record<string, unknown>,
46
+ base: string,
47
+ options: OAuthTokenExchangeOptions,
48
+ ): Promise<Response> {
49
+ const code = String(body.code ?? "");
50
+ const verifier = String(body.code_verifier ?? "");
51
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
52
+ return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
53
+ }
54
+ return options.withLock(async () => {
55
+ const oauthStore = await options.loadOAuthStore();
56
+ const refreshStore = await loadRefreshStore(oauthStore, options);
57
+ const record = oauthStore.codes[code];
58
+ if (!record) return json({ error: "invalid_grant" }, 400);
59
+ if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
60
+ return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
61
+ }
62
+ if (String(body.resource ?? record.resource) !== record.resource || record.resource !== `${base}/mcp`) {
63
+ return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
64
+ }
65
+ if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
66
+ return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
67
+ }
68
+ const client = oauthStore.clients[record.client_id];
69
+ if (!client) return json({ error: "invalid_grant", error_description: "unknown client" }, 400);
70
+
71
+ const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
72
+ delete oauthStore.codes[code];
73
+ client.last_used_at = Math.floor(Date.now() / 1000);
74
+ await saveOAuthStores(oauthStore, refreshStore, options.storage);
75
+ return tokenResponse(issued, record.scope);
76
+ });
77
+ }
78
+
79
+ async function exchangeRefreshToken(
80
+ body: Record<string, unknown>,
81
+ base: string,
82
+ options: OAuthTokenExchangeOptions,
83
+ ): Promise<Response> {
84
+ const refreshToken = String(body.refresh_token ?? "");
85
+ if (!/^mcp_rt_[A-Za-z0-9_-]{43}$/.test(refreshToken)) return json({ error: "invalid_grant" }, 400);
86
+ return options.withLock(async () => {
87
+ const oauthStore = await options.loadOAuthStore();
88
+ const refreshStore = await loadRefreshStore(oauthStore, options);
89
+ const refreshKey = `sha256:${await sha256Hex(refreshToken)}`;
90
+ const record = refreshStore.tokens[refreshKey];
91
+ if (!record) return json({ error: "invalid_grant" }, 400);
92
+ if (String(body.client_id ?? "") !== record.client_id) {
93
+ return json({ error: "invalid_grant", error_description: "client mismatch" }, 400);
94
+ }
95
+ if (String(body.resource ?? record.resource) !== record.resource || record.resource !== `${base}/mcp`) {
96
+ return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
97
+ }
98
+ if (body.scope !== undefined && normalizeOAuthScope(body.scope, options.serverName) !== record.scope) {
99
+ return json({ error: "invalid_scope" }, 400);
100
+ }
101
+ if (!options.tokenVersion) {
102
+ throw new HttpError(503, "server_error", "OAuth token version is not configured");
103
+ }
104
+ const account = oauthStore.accounts[record.account_id];
105
+ const client = oauthStore.clients[record.client_id];
106
+ if (
107
+ !record.version
108
+ || !(await safeEqual(record.version, options.tokenVersion))
109
+ || !account
110
+ || !account.active
111
+ || account.version !== record.account_version
112
+ || account.role !== record.role
113
+ || !client
114
+ ) {
115
+ delete refreshStore.tokens[refreshKey];
116
+ await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
117
+ return json({ error: "invalid_grant" }, 400);
118
+ }
119
+
120
+ const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
121
+ delete refreshStore.tokens[refreshKey];
122
+ client.last_used_at = Math.floor(Date.now() / 1000);
123
+ await saveOAuthStores(oauthStore, refreshStore, options.storage);
124
+ return tokenResponse(issued, record.scope);
125
+ });
126
+ }
127
+
128
+ async function loadRefreshStore(
129
+ oauthStore: OAuthStore,
130
+ options: OAuthTokenExchangeOptions,
131
+ ): Promise<OAuthRefreshStore> {
132
+ const raw = await options.storage.get<unknown>(OAUTH_REFRESH_STORE_KEY);
133
+ if (raw !== undefined && !isCurrentOAuthRefreshStore(raw)) {
134
+ throw new HttpError(503, "oauth_refresh_state_schema_mismatch", "OAuth refresh-token state requires operator repair");
135
+ }
136
+ const store = isCurrentOAuthRefreshStore(raw) ? raw : emptyOAuthRefreshStore();
137
+ let changed = false;
138
+ const now = Math.floor(Date.now() / 1000);
139
+ for (const [token, value] of Object.entries(store.tokens)) {
140
+ const account = oauthStore.accounts[value.account_id];
141
+ const client = oauthStore.clients[value.client_id];
142
+ if (
143
+ value.expires_at <= now
144
+ || !account
145
+ || !account.active
146
+ || account.version !== value.account_version
147
+ || account.role !== value.role
148
+ || !client
149
+ ) {
150
+ delete store.tokens[token];
151
+ changed = true;
152
+ }
153
+ }
154
+ if (changed) await options.storage.put(OAUTH_REFRESH_STORE_KEY, store);
155
+ return store;
156
+ }
157
+
158
+ async function issueTokenPair(
159
+ oauthStore: OAuthStore,
160
+ refreshStore: OAuthRefreshStore,
161
+ source: OAuthCode | OAuthRefreshToken,
162
+ tokenVersion: string,
163
+ ): Promise<IssuedTokenPair> {
164
+ if (!tokenVersion) throw new HttpError(503, "server_error", "OAuth token version is not configured");
165
+ const now = Math.floor(Date.now() / 1000);
166
+ const accessToken = randomToken("mcp_at");
167
+ const refreshToken = randomToken("mcp_rt");
168
+ const common = {
169
+ client_id: source.client_id,
170
+ account_id: source.account_id,
171
+ account_version: source.account_version,
172
+ role: source.role,
173
+ scope: source.scope,
174
+ resource: source.resource,
175
+ version: tokenVersion,
176
+ };
177
+ oauthStore.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
178
+ ...common,
179
+ expires_at: now + ACCESS_TOKEN_TTL_SECONDS,
180
+ };
181
+ refreshStore.tokens[`sha256:${await sha256Hex(refreshToken)}`] = {
182
+ ...common,
183
+ expires_at: now + REFRESH_TOKEN_TTL_SECONDS,
184
+ };
185
+ pruneClientRecordByExpiry(oauthStore.tokens, source.client_id, MAX_ACCESS_TOKENS_PER_CLIENT);
186
+ pruneRecordByExpiry(oauthStore.tokens, MAX_ACCESS_TOKENS);
187
+ pruneClientRecordByExpiry(refreshStore.tokens, source.client_id, MAX_REFRESH_TOKENS_PER_CLIENT);
188
+ pruneRecordByExpiry(refreshStore.tokens, MAX_REFRESH_TOKENS);
189
+ return { accessToken, refreshToken };
190
+ }
191
+
192
+ function tokenResponse(issued: IssuedTokenPair, scope: string): Response {
193
+ return json({
194
+ access_token: issued.accessToken,
195
+ refresh_token: issued.refreshToken,
196
+ token_type: "Bearer",
197
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
198
+ scope,
199
+ });
200
+ }
201
+
202
+ async function saveOAuthStores(
203
+ oauthStore: OAuthStore,
204
+ refreshStore: OAuthRefreshStore,
205
+ storage: DurableObjectStorage,
206
+ ): Promise<void> {
207
+ await storage.put({ oauth: oauthStore, [OAUTH_REFRESH_STORE_KEY]: refreshStore });
208
+ }