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 +8 -0
- package/README.md +17 -9
- package/SECURITY.md +4 -4
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +8 -8
- package/docs/AUDIT.md +8 -0
- package/docs/CLIENTS.md +15 -7
- package/docs/GETTING_STARTED.md +35 -5
- package/docs/LOGGING.md +1 -1
- package/docs/MULTI_ACCOUNT.md +1 -1
- package/docs/OPERATIONS.md +6 -0
- package/docs/TESTING.md +1 -1
- package/package.json +1 -1
- package/src/worker/index.ts +23 -53
- package/src/worker/oauth-state.ts +37 -2
- package/src/worker/oauth-tokens.ts +208 -0
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
33
|
+
"version_name": "1.1.0"
|
|
34
34
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/docs/GETTING_STARTED.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Installation and first-use guide
|
|
2
2
|
|
|
3
|
-
This guide covers a clean installation,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/docs/MULTI_ACCOUNT.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/src/worker/index.ts
CHANGED
|
@@ -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,
|
|
15
|
-
|
|
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
|
|
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.
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|