machine-bridge-mcp 1.0.7 → 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 +16 -0
- package/README.md +19 -9
- package/SECURITY.md +4 -4
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +9 -9
- package/docs/AUDIT.md +8 -0
- package/docs/CLIENTS.md +16 -8
- package/docs/GETTING_STARTED.md +35 -5
- package/docs/LOGGING.md +1 -1
- package/docs/MULTI_ACCOUNT.md +3 -1
- package/docs/OPERATIONS.md +8 -1
- package/docs/TESTING.md +1 -1
- package/docs/TOOL_REFERENCE.md +2 -2
- package/package.json +1 -1
- package/src/shared/server-metadata.json +2 -2
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/authority.ts +146 -0
- package/src/worker/index.ts +42 -63
- package/src/worker/oauth-state.ts +37 -2
- package/src/worker/oauth-tokens.ts +208 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
11
|
+
## 1.0.8 - 2026-07-14
|
|
12
|
+
|
|
13
|
+
### Effective account authority reporting
|
|
14
|
+
|
|
15
|
+
- Separate the authenticated account authority from the local daemon capability ceiling in remote diagnostics. `server_info.authorization.effective_policy` and `effective_tools` are now the authoritative account fields, while `daemon.policy` and `daemon.tools` are explicitly scoped as pre-role ceilings. The response also includes a deterministic authority summary and scope labels so clients cannot reasonably equate an `editor` account with a `full` daemon.
|
|
16
|
+
- Make remote `project_overview` return the role-intersected policy and tools at its existing top-level fields, while preserving the daemon values separately as `daemonPolicy` and `daemonTools`. Add a Worker integration regression that authorizes an `editor` account against a canonical `full` daemon and proves shell/browser tools remain excluded at both reporting surfaces and at relay enforcement.
|
|
17
|
+
- Update MCP initialization guidance, tool descriptions, operations diagnostics, architecture, client, account, testing, and generated tool-reference documentation to use the effective-account fields when diagnosing permissions.
|
|
18
|
+
|
|
3
19
|
## 1.0.7 - 2026-07-14
|
|
4
20
|
|
|
5
21
|
### Browser-complete OAuth callback navigation
|
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,23 @@ 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.
|
|
154
|
+
|
|
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`.
|
|
146
156
|
|
|
147
157
|
This is application-level authorization, not kernel or browser-profile isolation. All accounts reach one daemon running as one OS user. Use separate Workers and preferably separate low-privilege OS accounts, containers, or VMs for mutually untrusted users or hard tenant isolation. See [Multi-account authorization and tenancy](docs/MULTI_ACCOUNT.md).
|
|
148
158
|
|
|
@@ -217,7 +227,7 @@ machine-mcp browser status
|
|
|
217
227
|
|
|
218
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).
|
|
219
229
|
|
|
220
|
-
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.”
|
|
221
231
|
|
|
222
232
|
|
|
223
233
|
## Runtime lifecycle and observability
|
|
@@ -470,7 +480,7 @@ The service definition contains neither credentials nor a duplicate policy. It l
|
|
|
470
480
|
machine-mcp rotate-secrets
|
|
471
481
|
```
|
|
472
482
|
|
|
473
|
-
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.
|
|
474
484
|
|
|
475
485
|
## State and observability
|
|
476
486
|
|
|
@@ -497,7 +507,7 @@ npm audit --omit=dev --audit-level=high
|
|
|
497
507
|
npm pack --dry-run
|
|
498
508
|
```
|
|
499
509
|
|
|
500
|
-
`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.
|
|
501
511
|
|
|
502
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).
|
|
503
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,13 +233,13 @@ 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
|
|
|
242
|
-
Public health exposes only server identity and version. Authenticated `server_info` exposes bounded runtime status,
|
|
242
|
+
Public health exposes only server identity and version. Authenticated `server_info` exposes bounded runtime status, managed-job counts, resource alias names without paths or values, relay route state without endpoint details, and privacy-preserving capability-routing evidence. It separates the daemon capability ceiling from the authenticated account authority: `daemon.policy`/`daemon.tools` retain the pre-role ceiling, while `authorization.effective_policy`/`authorization.effective_tools` and the top-level `tools` report the role-intersected authority before any host-side filtering. It explicitly reports that the host-exposed subset is unknown to the server. `diagnose_runtime` runs fixed local probes and explicitly reports that its own request reached the daemon.
|
|
243
243
|
|
|
244
244
|
Foreground logging defaults to `info`; autostart uses `warn`. Authenticated readiness, persistent degradation, and recovery are user-visible state transitions. Brief relay interruptions, raw transport close details, retry timing, and all per-tool starts/successes/failures/cancellations/durations are debug-only. Unexpected local and Worker infrastructure errors are reduced to classes. Messages, strings, arrays, object depth/key counts, and serialized fields are bounded.
|
|
245
245
|
|
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
|
|
|
@@ -149,7 +157,7 @@ For SSH automation, prefer `generate_ssh_key_resource` under canonical full, or
|
|
|
149
157
|
|
|
150
158
|
The local `full` profile controls Machine Bridge's own tool catalog, path resolver, path display, process environment, and shell availability. It does not control the MCP host's model policy, approval UI, connector gateway, or platform execution filters.
|
|
151
159
|
|
|
152
|
-
Machine Bridge itself does not block files because their names look sensitive.
|
|
160
|
+
Machine Bridge itself does not block files because their names look sensitive. In remote mode, first inspect `server_info.authorization.effective_policy` and `effective_tools`; `daemon.policy` is only the local ceiling. If the effective profile is `full` and the effective tool is present but a direct call is still rejected before a structured result, the host/connector may have blocked delivery. If `diagnose_runtime` responds but its fixed process or shell probe fails, the likely source is local OS policy, endpoint-security software, permissions, or shell configuration. Changing `--profile`, `--unrestricted-paths`, or `--absolute-paths` cannot override either layer.
|
|
153
161
|
|
|
154
162
|
Do not attempt to evade a host refusal by renaming, encoding, or switching to another arbitrary execution tool. Instead:
|
|
155
163
|
|
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
|
|
|
@@ -25,6 +25,8 @@ The effective tool set is the intersection of:
|
|
|
25
25
|
|
|
26
26
|
The Worker filters `tools/list` and rejects unauthorized calls before relay. Every accepted relay call also carries `account_id`, `account_version`, and `role`; the local runtime validates the role again before dispatch. The shared policy contract remains the single source of capability semantics.
|
|
27
27
|
|
|
28
|
+
Authenticated `server_info` deliberately reports both layers. `authorization.effective_policy` and `authorization.effective_tools` are authoritative for the current account. The nested `daemon.policy` and `daemon.tools` fields are only the local capability ceiling before account-role filtering; a `full` daemon does not make an `editor` account full. Remote `project_overview` uses the effective values at top-level `policy` and `tools` and preserves the daemon values as `daemonPolicy` and `daemonTools`.
|
|
29
|
+
|
|
28
30
|
## Account lifecycle
|
|
29
31
|
|
|
30
32
|
List accounts:
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -14,7 +14,8 @@ machine-mcp service status
|
|
|
14
14
|
|
|
15
15
|
| Result | Interpretation |
|
|
16
16
|
|---|---|
|
|
17
|
-
| `
|
|
17
|
+
| `authorization.effective_policy.profile` is `full` and the tool is in `authorization.effective_tools`, but the current session UI exposes fewer tools | Host/connector post-relay filtering; Machine Bridge cannot enumerate or override that subset |
|
|
18
|
+
| `daemon.policy.profile` is `full` but `authorization.effective_policy.profile` is `review`, `edit`, or `agent` | Expected account-role narrowing; the daemon field is only a capability ceiling and must not be reported as the account permission |
|
|
18
19
|
| `capability_routing.bootstrap_observed` is false | The current local runtime has not received `session_bootstrap`; reconnect or inspect host initialization handling |
|
|
19
20
|
| `task_resolution_observed` is false after a substantive task | The host/model did not call `resolve_task_capabilities`; server-side discovery cannot force that host decision |
|
|
20
21
|
| Task resolution ran but all match counts are zero | The resolver ran successfully but found no sufficiently relevant local skill, command, or application |
|
|
@@ -197,6 +198,12 @@ Uninstall refuses to remove local state while any managed job remains active. Ac
|
|
|
197
198
|
|
|
198
199
|
See [MANAGED_JOBS.md](MANAGED_JOBS.md).
|
|
199
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
|
+
|
|
200
207
|
## Reconnect and replacement
|
|
201
208
|
|
|
202
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/docs/TOOL_REFERENCE.md
CHANGED
|
@@ -66,7 +66,7 @@ Tool count: **51**.
|
|
|
66
66
|
|
|
67
67
|
**Server information**
|
|
68
68
|
|
|
69
|
-
Return
|
|
69
|
+
Return authenticated account authority, effective policy/tools, daemon capability ceiling, runtime metadata, and protocol status. Treat authorization.effective_policy and authorization.effective_tools as authoritative; daemon.policy is only a ceiling.
|
|
70
70
|
|
|
71
71
|
| Contract field | Value |
|
|
72
72
|
|---|---|
|
|
@@ -89,7 +89,7 @@ Return bridge/runtime metadata, active policy, protocol versions, and available
|
|
|
89
89
|
|
|
90
90
|
**Project overview**
|
|
91
91
|
|
|
92
|
-
Summarize the connected workspace
|
|
92
|
+
Summarize the connected workspace and repository. Remote responses report the authenticated account effective policy/tools at policy and tools, with the daemon capability ceiling preserved separately as daemonPolicy and daemonTools.
|
|
93
93
|
|
|
94
94
|
| Contract field | Value |
|
|
95
95
|
|---|---|
|
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",
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
"For existing-browser work, use the paired Machine Bridge browser extension. It operates the user current Chromium profile and tabs, can inspect DOM/source, fill complex forms, and inject sensitive text from registered local resources without returning those values.",
|
|
13
13
|
"For local application work, use structured application discovery and Accessibility actions. Arbitrary caller-supplied AppleScript or JavaScript is intentionally not accepted.",
|
|
14
14
|
"Remote mode uses a Cloudflare relay; stdio mode runs on the local machine. File and command operations execute on the user's local runtime, not in the Worker.",
|
|
15
|
-
"
|
|
15
|
+
"In remote multi-account mode, account authority is the intersection of the authenticated account role and the daemon capability ceiling. Only authorization.effective_policy and authorization.effective_tools describe that account authority; daemon.policy is not the account permission.",
|
|
16
16
|
"Filename sensitivity is not classified by this server; the MCP host, connector gateway, local OS, or endpoint-security software may enforce additional independent rules.",
|
|
17
17
|
"A named full profile is canonical: it always enables writes, shell execution, unrestricted paths, the full parent environment, absolute paths, and the complete tool catalog. Any explicit narrowing is stored as custom.",
|
|
18
|
-
"The full catalog
|
|
18
|
+
"The local daemon may advertise the full catalog as its capability ceiling. In remote mode, the Worker relays only the intersection allowed by the authenticated account role; a connector host may then expose an even smaller subset that Machine Bridge cannot observe or override.",
|
|
19
19
|
"Use diagnose_runtime to distinguish a request that reached the daemon from local filesystem, process-spawn, shell, managed-job-storage, and resource failures. A tool call blocked before any response cannot be diagnosed by the server.",
|
|
20
20
|
"Never request or return secret-file contents when a local resource alias can be used. Resources are registered through the local machine-mcp CLI; under canonical full policy, generate_ssh_key_resource can generate and register an Ed25519 key without returning private content and may be injected by path, stdin, or environment without entering MCP arguments.",
|
|
21
21
|
"If execution-class tools are unavailable but write access remains, use stage_job to persist a reviewed plan without running it; the operator can launch it locally with machine-mcp job approve JOB_ID.",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{
|
|
3
3
|
"name": "server_info",
|
|
4
4
|
"title": "Server information",
|
|
5
|
-
"description": "Return
|
|
5
|
+
"description": "Return authenticated account authority, effective policy/tools, daemon capability ceiling, runtime metadata, and protocol status. Treat authorization.effective_policy and authorization.effective_tools as authoritative; daemon.policy is only a ceiling.",
|
|
6
6
|
"availability": "always",
|
|
7
7
|
"annotations": {
|
|
8
8
|
"readOnlyHint": true,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
{
|
|
19
19
|
"name": "project_overview",
|
|
20
20
|
"title": "Project overview",
|
|
21
|
-
"description": "Summarize the connected workspace
|
|
21
|
+
"description": "Summarize the connected workspace and repository. Remote responses report the authenticated account effective policy/tools at policy and tools, with the daemon capability ceiling preserved separately as daemonPolicy and daemonTools.",
|
|
22
22
|
"availability": "always",
|
|
23
23
|
"annotations": {
|
|
24
24
|
"readOnlyHint": true,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import policyContract from "../shared/policy-contract.json";
|
|
2
|
+
import { accountRolePolicy, accountRoleToolNames, type AccountRole } from "./access";
|
|
3
|
+
import type { DaemonPolicy } from "./policy";
|
|
4
|
+
|
|
5
|
+
type EffectivePolicy = {
|
|
6
|
+
scope: "authenticated_account_effective_authority";
|
|
7
|
+
derived_from: ["account_role_policy", "daemon_capability_ceiling"];
|
|
8
|
+
profile: string;
|
|
9
|
+
revision: number;
|
|
10
|
+
allowWrite: boolean;
|
|
11
|
+
allowExec: boolean;
|
|
12
|
+
execMode: DaemonPolicy["execMode"];
|
|
13
|
+
unrestrictedPaths: boolean;
|
|
14
|
+
minimalEnv: boolean;
|
|
15
|
+
exposeAbsolutePaths: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type AccountIdentity = { accountId: string; accountVersion: number; role: AccountRole };
|
|
19
|
+
|
|
20
|
+
type AccountAuthority = {
|
|
21
|
+
account: { account_id: string; role: AccountRole; version: number };
|
|
22
|
+
account_policy: DaemonPolicy & { scope: "account_role_capability_ceiling" };
|
|
23
|
+
daemon_policy_ceiling: DaemonPolicy | null;
|
|
24
|
+
effective_policy: EffectivePolicy | null;
|
|
25
|
+
effective_tools: string[];
|
|
26
|
+
effective_tool_count: number;
|
|
27
|
+
account_role_is_owner: boolean;
|
|
28
|
+
effective_profile_is_full: boolean;
|
|
29
|
+
interpretation: {
|
|
30
|
+
authoritative_permission_fields: ["effective_policy", "effective_tools"];
|
|
31
|
+
daemon_policy: string;
|
|
32
|
+
host_filtering: string;
|
|
33
|
+
};
|
|
34
|
+
summary: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DaemonCeilingStatus = Record<string, unknown> & {
|
|
38
|
+
policy: DaemonPolicy | null;
|
|
39
|
+
tools: string[];
|
|
40
|
+
policy_scope: "daemon_capability_ceiling_not_account_authority";
|
|
41
|
+
tools_scope: "daemon_advertised_before_account_role_filtering";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const profileFields = ["allowWrite", "execMode", "unrestrictedPaths", "minimalEnv", "exposeAbsolutePaths"] as const;
|
|
45
|
+
const profiles = policyContract.profiles as Record<string, Pick<DaemonPolicy, typeof profileFields[number]> & { profile: string }>;
|
|
46
|
+
const execRank: Record<DaemonPolicy["execMode"], number> = { off: 0, direct: 1, shell: 2 };
|
|
47
|
+
|
|
48
|
+
export function describeDaemonCeiling(value: Record<string, unknown>): DaemonCeilingStatus {
|
|
49
|
+
return {
|
|
50
|
+
...value,
|
|
51
|
+
policy: isDaemonPolicy(value.policy) ? value.policy : null,
|
|
52
|
+
tools: Array.isArray(value.tools) ? value.tools.filter((item): item is string => typeof item === "string") : [],
|
|
53
|
+
policy_scope: "daemon_capability_ceiling_not_account_authority",
|
|
54
|
+
tools_scope: "daemon_advertised_before_account_role_filtering",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function accountAuthoritySnapshot(input: AccountIdentity & {
|
|
59
|
+
daemonPolicy: DaemonPolicy | null;
|
|
60
|
+
effectiveTools: string[];
|
|
61
|
+
}): AccountAuthority {
|
|
62
|
+
const accountPolicy = accountRolePolicy(input.role);
|
|
63
|
+
const effectivePolicy = input.daemonPolicy ? intersectPolicies(accountPolicy, input.daemonPolicy) : null;
|
|
64
|
+
const effectiveTools = [...new Set(input.effectiveTools)];
|
|
65
|
+
const daemonProfile = input.daemonPolicy?.profile ?? "disconnected";
|
|
66
|
+
const effectiveProfile = effectivePolicy?.profile ?? "unavailable";
|
|
67
|
+
return {
|
|
68
|
+
account: { account_id: input.accountId, role: input.role, version: input.accountVersion },
|
|
69
|
+
account_policy: { ...accountPolicy, scope: "account_role_capability_ceiling" },
|
|
70
|
+
daemon_policy_ceiling: input.daemonPolicy,
|
|
71
|
+
effective_policy: effectivePolicy,
|
|
72
|
+
effective_tools: effectiveTools,
|
|
73
|
+
effective_tool_count: effectiveTools.length,
|
|
74
|
+
account_role_is_owner: input.role === "owner",
|
|
75
|
+
effective_profile_is_full: effectivePolicy?.profile === "full",
|
|
76
|
+
interpretation: {
|
|
77
|
+
authoritative_permission_fields: ["effective_policy", "effective_tools"],
|
|
78
|
+
daemon_policy: "daemon.policy is only the local daemon capability ceiling; it is not the authenticated account permission",
|
|
79
|
+
host_filtering: "the MCP host may expose a smaller subset than effective_tools, and Machine Bridge cannot observe that post-relay subset",
|
|
80
|
+
},
|
|
81
|
+
summary: input.daemonPolicy
|
|
82
|
+
? `Authenticated account role ${input.role} has effective profile ${effectiveProfile}. daemon.policy.profile=${daemonProfile} is only the daemon capability ceiling, not this account's permission.`
|
|
83
|
+
: `Authenticated account role ${input.role} is configured for profile ${accountPolicy.profile}, but no daemon is connected; only Worker-local tools are currently available.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decorateProjectOverview(value: unknown, account: AccountIdentity): unknown {
|
|
88
|
+
if (!isRecord(value)) return value;
|
|
89
|
+
const daemonPolicy = isDaemonPolicy(value.policy) ? value.policy : null;
|
|
90
|
+
const daemonTools = Array.isArray(value.tools) ? value.tools.filter((item): item is string => typeof item === "string") : [];
|
|
91
|
+
const effectiveTools = ["server_info", ...accountRoleToolNames(account.role, daemonTools)];
|
|
92
|
+
const authority = accountAuthoritySnapshot({ ...account, daemonPolicy, effectiveTools });
|
|
93
|
+
return {
|
|
94
|
+
...value,
|
|
95
|
+
daemonPolicy,
|
|
96
|
+
daemonTools,
|
|
97
|
+
policy: authority.effective_policy,
|
|
98
|
+
tools: authority.effective_tools,
|
|
99
|
+
policyScope: "authenticated_account_effective_authority",
|
|
100
|
+
toolsScope: "authenticated_account_effective_tools_before_host_filtering",
|
|
101
|
+
authorization: authority,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function intersectPolicies(accountPolicy: DaemonPolicy, daemonPolicy: DaemonPolicy): EffectivePolicy {
|
|
106
|
+
const execMode = execRank[accountPolicy.execMode] <= execRank[daemonPolicy.execMode] ? accountPolicy.execMode : daemonPolicy.execMode;
|
|
107
|
+
const capabilities = {
|
|
108
|
+
allowWrite: accountPolicy.allowWrite && daemonPolicy.allowWrite,
|
|
109
|
+
execMode,
|
|
110
|
+
unrestrictedPaths: accountPolicy.unrestrictedPaths && daemonPolicy.unrestrictedPaths,
|
|
111
|
+
minimalEnv: accountPolicy.minimalEnv || daemonPolicy.minimalEnv,
|
|
112
|
+
exposeAbsolutePaths: accountPolicy.exposeAbsolutePaths && daemonPolicy.exposeAbsolutePaths,
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
scope: "authenticated_account_effective_authority",
|
|
116
|
+
derived_from: ["account_role_policy", "daemon_capability_ceiling"],
|
|
117
|
+
profile: matchingProfile(capabilities),
|
|
118
|
+
revision: Math.max(accountPolicy.revision, daemonPolicy.revision),
|
|
119
|
+
...capabilities,
|
|
120
|
+
allowExec: execMode !== "off",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function matchingProfile(capabilities: Pick<EffectivePolicy, typeof profileFields[number]>): string {
|
|
125
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
126
|
+
if (profileFields.every((field) => profile[field] === capabilities[field])) return name;
|
|
127
|
+
}
|
|
128
|
+
return "custom";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isDaemonPolicy(value: unknown): value is DaemonPolicy {
|
|
132
|
+
if (!isRecord(value)) return false;
|
|
133
|
+
return typeof value.profile === "string"
|
|
134
|
+
&& typeof value.origin === "string"
|
|
135
|
+
&& Number.isInteger(value.revision)
|
|
136
|
+
&& typeof value.allowWrite === "boolean"
|
|
137
|
+
&& typeof value.allowExec === "boolean"
|
|
138
|
+
&& (value.execMode === "off" || value.execMode === "direct" || value.execMode === "shell")
|
|
139
|
+
&& typeof value.unrestrictedPaths === "boolean"
|
|
140
|
+
&& typeof value.minimalEnv === "boolean"
|
|
141
|
+
&& typeof value.exposeAbsolutePaths === "boolean";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
145
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
146
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -7,11 +7,13 @@ import { WorkerObservability } from "./observability";
|
|
|
7
7
|
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
|
|
8
8
|
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
|
|
9
9
|
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
|
|
10
|
+
import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority";
|
|
10
11
|
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin";
|
|
12
|
+
import { exchangeOAuthToken } from "./oauth-tokens";
|
|
11
13
|
import { serverInfoTool, workspaceTools } from "./tool-catalog";
|
|
12
14
|
import {
|
|
13
|
-
AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
|
|
14
|
-
|
|
15
|
+
AUTH_BLOCK_SECONDS, OFFLINE_ACCESS_SCOPE, accountByName, authorizationIdentity, emptyOAuthStore,
|
|
16
|
+
isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
|
|
15
17
|
recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
|
|
16
18
|
type OAuthClient, type OAuthStore, type ValidatedAuthorization,
|
|
17
19
|
} from "./oauth-state";
|
|
@@ -22,13 +24,12 @@ import {
|
|
|
22
24
|
} from "./http";
|
|
23
25
|
|
|
24
26
|
const SERVER_NAME = String(serverMetadata.name);
|
|
25
|
-
const SERVER_VERSION = "1.0
|
|
27
|
+
const SERVER_VERSION = "1.1.0";
|
|
26
28
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
27
29
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
28
30
|
const JSONRPC_VERSION = "2.0";
|
|
29
31
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
30
32
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
31
|
-
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
32
33
|
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
33
34
|
const MAX_PENDING_CALLS = 32;
|
|
34
35
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -37,10 +38,8 @@ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
|
|
|
37
38
|
const MAX_OAUTH_CLIENTS = 50;
|
|
38
39
|
const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
|
|
39
40
|
const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
40
|
-
const MAX_TOKENS_PER_CLIENT = 20;
|
|
41
41
|
const MAX_CODES_PER_CLIENT = 10;
|
|
42
42
|
const MAX_OAUTH_CODES = 200;
|
|
43
|
-
const MAX_OAUTH_TOKENS = 500;
|
|
44
43
|
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
45
44
|
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
46
45
|
|
|
@@ -342,17 +341,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
342
341
|
}
|
|
343
342
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
344
343
|
}
|
|
345
|
-
|
|
346
344
|
private async callTool(name: string, args: Record<string, unknown>, base: string, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
|
|
347
345
|
if (name === "server_info") {
|
|
348
|
-
const daemon = this.
|
|
349
|
-
const tools = this.allTools(authorized.role).map((tool) => tool.name);
|
|
346
|
+
const { daemon, tools, authorization } = this.authorityContext(authorized);
|
|
350
347
|
return {
|
|
351
348
|
name: SERVER_NAME,
|
|
352
349
|
version: SERVER_VERSION,
|
|
353
350
|
mcp_url: `${base}/mcp`,
|
|
354
351
|
oauth: this.authorizationServerMetadata(base),
|
|
355
|
-
account:
|
|
352
|
+
account: authorization.account,
|
|
353
|
+
authorization,
|
|
354
|
+
authority_summary: authorization.summary,
|
|
356
355
|
daemon,
|
|
357
356
|
worker: {
|
|
358
357
|
pending_calls: this.pending.snapshot(),
|
|
@@ -360,10 +359,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
360
359
|
observability: this.observability.snapshot(),
|
|
361
360
|
},
|
|
362
361
|
tools,
|
|
362
|
+
tools_scope: "authenticated_account_effective_tools_before_host_filtering",
|
|
363
363
|
tool_delivery: {
|
|
364
|
-
full_profile_scope: "
|
|
364
|
+
full_profile_scope: "daemon-capability-ceiling-before-account-filtering",
|
|
365
365
|
daemon_advertised_tool_count: daemon.tool_count,
|
|
366
366
|
relay_advertised_tool_count: tools.length,
|
|
367
|
+
effective_account_tool_count: tools.length,
|
|
368
|
+
relay_advertised_scope: "authenticated_account_effective_tools_before_host_filtering",
|
|
367
369
|
host_exposed_tools_known_to_server: false,
|
|
368
370
|
host_may_expose_subset: true,
|
|
369
371
|
},
|
|
@@ -372,11 +374,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
372
374
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
373
375
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
374
376
|
if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
|
|
375
|
-
|
|
377
|
+
const result = await this.callDaemonTool(name, args, authorized, requestKey);
|
|
378
|
+
return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
|
|
379
|
+
accountVersion: authorized.accountVersion, role: authorized.role }) : result;
|
|
376
380
|
}
|
|
377
381
|
throw new Error(`unknown tool: ${name}`);
|
|
378
382
|
}
|
|
379
|
-
|
|
380
383
|
private async callDaemonTool(name: string, args: Record<string, unknown>, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
|
|
381
384
|
const socket = this.daemonSockets()[0];
|
|
382
385
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
@@ -419,7 +422,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
419
422
|
throw error;
|
|
420
423
|
}
|
|
421
424
|
}
|
|
422
|
-
|
|
423
425
|
private cancelClientRequest(requestKey?: string): void {
|
|
424
426
|
if (!requestKey) return;
|
|
425
427
|
this.pending.cancelRequest(requestKey, (record) => {
|
|
@@ -456,11 +458,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
456
458
|
const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
|
|
457
459
|
return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
|
|
458
460
|
}
|
|
459
|
-
|
|
461
|
+
private authorityContext(authorized: AuthorizedToken) {
|
|
462
|
+
const daemon = describeDaemonCeiling(this.daemonStatus(true));
|
|
463
|
+
const tools = this.allTools(authorized.role).map((tool) => String(tool.name));
|
|
464
|
+
const authorization = accountAuthoritySnapshot({ accountId: authorized.accountId, accountVersion: authorized.accountVersion,
|
|
465
|
+
role: authorized.role, daemonPolicy: daemon.policy, effectiveTools: tools });
|
|
466
|
+
return { daemon, tools, authorization };
|
|
467
|
+
}
|
|
460
468
|
private daemonToolEnabled(name: string): boolean {
|
|
461
469
|
return this.daemonAdvertisedTools().has(name);
|
|
462
470
|
}
|
|
463
|
-
|
|
464
471
|
private daemonAdvertisedTools(): Set<string> {
|
|
465
472
|
const socket = this.daemonSockets()[0];
|
|
466
473
|
if (!socket) return new Set();
|
|
@@ -582,10 +589,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
582
589
|
token_endpoint: `${base}/oauth/token`,
|
|
583
590
|
registration_endpoint: `${base}/oauth/register`,
|
|
584
591
|
response_types_supported: ["code"],
|
|
585
|
-
grant_types_supported: ["authorization_code"],
|
|
592
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
586
593
|
token_endpoint_auth_methods_supported: ["none"],
|
|
587
594
|
code_challenge_methods_supported: ["S256"],
|
|
588
|
-
scopes_supported: [SERVER_NAME],
|
|
595
|
+
scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
|
|
589
596
|
};
|
|
590
597
|
}
|
|
591
598
|
|
|
@@ -593,7 +600,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
593
600
|
return {
|
|
594
601
|
resource: `${base}/mcp`,
|
|
595
602
|
authorization_servers: [base],
|
|
596
|
-
scopes_supported: [SERVER_NAME],
|
|
603
|
+
scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
|
|
597
604
|
bearer_methods_supported: ["header"],
|
|
598
605
|
resource_name: SERVER_NAME,
|
|
599
606
|
};
|
|
@@ -637,7 +644,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
637
644
|
delete client.registration_identity;
|
|
638
645
|
changed = true;
|
|
639
646
|
}
|
|
640
|
-
const ttl = client.
|
|
647
|
+
const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
641
648
|
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
642
649
|
delete store.clients[clientId];
|
|
643
650
|
changed = true;
|
|
@@ -680,9 +687,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
680
687
|
return this.withOAuthLock(async () => {
|
|
681
688
|
const store = await this.oauthStore();
|
|
682
689
|
const registrationIdentity = await authorizationIdentity(request, this.identityKey());
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
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);
|
|
686
695
|
}
|
|
687
696
|
if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
|
|
688
697
|
return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
|
|
@@ -694,6 +703,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
694
703
|
redirect_uris: normalized,
|
|
695
704
|
created_at: now,
|
|
696
705
|
last_used_at: now,
|
|
706
|
+
has_been_authorized: false,
|
|
697
707
|
registration_identity: registrationIdentity,
|
|
698
708
|
};
|
|
699
709
|
store.clients[client.client_id] = client;
|
|
@@ -702,7 +712,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
702
712
|
client_id: client.client_id,
|
|
703
713
|
client_name: client.client_name,
|
|
704
714
|
redirect_uris: client.redirect_uris,
|
|
705
|
-
grant_types: ["authorization_code"],
|
|
715
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
706
716
|
response_types: ["code"],
|
|
707
717
|
token_endpoint_auth_method: "none",
|
|
708
718
|
client_id_issued_at: client.created_at,
|
|
@@ -798,6 +808,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
798
808
|
}
|
|
799
809
|
delete store.auth_failures[identity];
|
|
800
810
|
client.last_used_at = now;
|
|
811
|
+
client.has_been_authorized = true;
|
|
801
812
|
|
|
802
813
|
const code = randomToken("mcp_code");
|
|
803
814
|
const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
|
|
@@ -820,45 +831,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
820
831
|
});
|
|
821
832
|
}
|
|
822
833
|
|
|
823
|
-
private
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
return this.withOAuthLock(async () => {
|
|
831
|
-
const store = await this.oauthStore();
|
|
832
|
-
const record = store.codes[code];
|
|
833
|
-
if (!record) return json({ error: "invalid_grant" }, 400);
|
|
834
|
-
if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
|
|
835
|
-
return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
|
|
836
|
-
}
|
|
837
|
-
if (String(body.resource ?? record.resource) !== record.resource) {
|
|
838
|
-
return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
|
|
839
|
-
}
|
|
840
|
-
if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
|
|
841
|
-
return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
845
|
-
if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
|
|
846
|
-
delete store.codes[code];
|
|
847
|
-
const accessToken = randomToken("mcp_at");
|
|
848
|
-
store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
849
|
-
client_id: record.client_id,
|
|
850
|
-
account_id: record.account_id,
|
|
851
|
-
account_version: record.account_version,
|
|
852
|
-
role: record.role,
|
|
853
|
-
scope: record.scope,
|
|
854
|
-
resource: `${base}/mcp`,
|
|
855
|
-
version: tokenVersion,
|
|
856
|
-
expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
|
857
|
-
};
|
|
858
|
-
pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
|
|
859
|
-
pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
|
|
860
|
-
await this.ctx.storage.put("oauth", store);
|
|
861
|
-
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),
|
|
862
841
|
});
|
|
863
842
|
}
|
|
864
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
|
+
}
|