@12-apps/mcp 2.0.0 → 3.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/ADOPTING.md +27 -16
- package/package.json +4 -4
- package/prisma/mcp.prisma +1 -1
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +6 -6
- package/scripts/sync-mcp-schema.mjs +2 -2
- package/src/coverage-gate/index.ts +1 -1
- package/src/coverage-gate/route-methods.ts +1 -1
- package/src/generate/index.ts +1 -1
- package/src/oauth/access-token.ts +1 -1
- package/src/oauth/authorization-code.ts +1 -1
- package/src/oauth/authorize.ts +1 -1
- package/src/oauth/clients.ts +2 -2
- package/src/oauth/code-replay.ts +1 -1
- package/src/oauth/config.ts +3 -3
- package/src/oauth/connections.ts +104 -0
- package/src/oauth/context.ts +5 -5
- package/src/oauth/index.ts +7 -0
- package/src/oauth/keys.ts +4 -4
- package/src/oauth/pkce.ts +1 -1
- package/src/oauth/refresh.ts +1 -1
- package/src/oauth/register.ts +1 -1
- package/src/oauth/token-grants.ts +2 -2
- package/src/oauth/token-response.ts +1 -1
- package/src/server/manifest.ts +1 -1
- package/src/server/surface-lock.ts +1 -1
package/ADOPTING.md
CHANGED
|
@@ -34,7 +34,7 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
34
34
|
`enabled: false` authorize/token/jwks/discovery answer **404** — a probe cannot
|
|
35
35
|
tell a disabled AS from an app that has none — while registration answers
|
|
36
36
|
**403 `access_denied`**, because RFC 7591 has a code for "the endpoint is here,
|
|
37
|
-
registration is closed".
|
|
37
|
+
registration is closed". The origin host passes
|
|
38
38
|
`enabled: () => process.env.MCP_BEARER_ENABLED === 'true'`, so the surface stays
|
|
39
39
|
OFF until an operator opts in.
|
|
40
40
|
4. **No signing key, no tokens.** `signingKey` defaults to the env-backed provider
|
|
@@ -51,7 +51,7 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
51
51
|
the internal origin rather than to an attacker's. Issuance and verification read
|
|
52
52
|
the same resolver from the same request, which is what stops "minted for A,
|
|
53
53
|
verified against B" from rejecting valid tokens.
|
|
54
|
-
`trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')` keeps
|
|
54
|
+
`trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')` keeps the origin host's wiring.
|
|
55
55
|
6. **The stores are narrow ports; Prisma fills them in one line.**
|
|
56
56
|
`createPrismaMcpStores(async () => prisma as unknown as McpOauthPrisma)`. A
|
|
57
57
|
non-Prisma host implements `OAuthClientStore` / `RefreshTokenStore` /
|
|
@@ -104,16 +104,19 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
104
104
|
default, and it is deliberately the inconvenient one.
|
|
105
105
|
10. **Connections are per USER, not per tenant** — an MCP bearer is
|
|
106
106
|
auth-passthrough. `connections.resolveUserId(email)` maps the token's email to
|
|
107
|
-
the host's user id (
|
|
107
|
+
the host's user id (the origin host resolves it by email because `session.user.id` is
|
|
108
108
|
the OAuth `sub`); returning `null` records nothing. Recording is best-effort and
|
|
109
109
|
FENCED: a failing directory can never turn a valid grant into a 500, and nothing
|
|
110
110
|
about the attempt is logged, because the only values in hand are an email and a
|
|
111
111
|
client id.
|
|
112
|
-
11. **Disconnecting means BOTH halves
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
live refresh token simply rotates its way back in
|
|
116
|
-
the next grant.
|
|
112
|
+
11. **Disconnecting means BOTH halves — call `disconnectAiHost`, not the stores.**
|
|
113
|
+
`connections.revokeByHost(...)` ends the rows and returns the OAuth client ids
|
|
114
|
+
behind them, and every live refresh token of those clients must be ended in the
|
|
115
|
+
same act — a host holding a live refresh token simply rotates its way back in
|
|
116
|
+
and the card lights green on the next grant. Since 12-48 the rule IS a
|
|
117
|
+
function: `disconnectAiHost(stores, { userId, email }, host)` does both and
|
|
118
|
+
reports what it ended, so a host cannot import one half without the other.
|
|
119
|
+
Neither half invalidates an outstanding ACCESS token: those are
|
|
117
120
|
self-contained JWTs, so a disconnected host keeps working for at most their
|
|
118
121
|
15-minute TTL and can then obtain nothing further.
|
|
119
122
|
12. **These bodies are NOT the `{ data }` envelope.** A 302 with a `Location`, RFC
|
|
@@ -211,7 +214,7 @@ Two things about the gates worth knowing before you adopt them:
|
|
|
211
214
|
committed exclusions file the moment it adopted the package, so it needs its own
|
|
212
215
|
burn-down).
|
|
213
216
|
|
|
214
|
-
## Phase B — adopting into a host that ALREADY has these tables (
|
|
217
|
+
## Phase B — adopting into a host that ALREADY has these tables (the origin host)
|
|
215
218
|
|
|
216
219
|
**Nothing to baseline.** Every statement in the package migration is guarded
|
|
217
220
|
(`CREATE TABLE IF NOT EXISTS`, `CREATE [UNIQUE] INDEX IF NOT EXISTS`, `ADD COLUMN
|
|
@@ -223,8 +226,8 @@ a host that already has `oauth_clients` / `oauth_refresh_tokens` /
|
|
|
223
226
|
Deliberate deltas to reconcile:
|
|
224
227
|
|
|
225
228
|
- **The FK from `mcp_connections.user_id` to `users` is not in the package
|
|
226
|
-
migration** — host vocabulary.
|
|
227
|
-
- **`onboarding_states` is not here.**
|
|
229
|
+
migration** — host vocabulary. The origin host keeps its `ON DELETE CASCADE`.
|
|
230
|
+
- **`onboarding_states` is not here.** The origin host's migration created it beside
|
|
228
231
|
`mcp_connections`; it belongs to `@12-apps/onboarding` (12-23).
|
|
229
232
|
- The host's `lib/mcp/oauth/**` (~1.5k LOC) and its four route files are replaced
|
|
230
233
|
by the mount plus, where a coverage gate forces the file to exist, a one-line
|
|
@@ -234,15 +237,23 @@ Deliberate deltas to reconcile:
|
|
|
234
237
|
|
|
235
238
|
## What deliberately did NOT move into the package
|
|
236
239
|
|
|
237
|
-
- **The account/connection SCREENS'
|
|
238
|
-
/api/account/mcp-connections`) — they
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
- **The account/connection SCREENS' route files** (`GET/DELETE
|
|
241
|
+
/api/account/mcp-connections`) — they answer in the HOST's app-wide response
|
|
242
|
+
envelope and mix its session resolution, published plugin URLs and logger, so
|
|
243
|
+
the handler stays host code (unlike the OAuth endpoints, whose shapes are
|
|
244
|
+
fixed by RFC — rule 12 — these are ordinary host API routes). What DID move
|
|
245
|
+
(12-48) is the operations under them: `listAiConnections` (the stored open
|
|
246
|
+
`host` string narrowed to the package's own `AiProvider` union) and
|
|
247
|
+
`disconnectAiHost`, which owns the disconnect's both-halves rule — revoke the
|
|
248
|
+
connection rows AND end every live refresh token of each returned client id in
|
|
249
|
+
one call. A host that imports the disconnect cannot get only half of it; half
|
|
250
|
+
is the failure mode where the assistant rotates its live token and the card
|
|
251
|
+
the user just disconnected lights green again on the next grant (rule 11).
|
|
241
252
|
- **The MCP registry itself** — which endpoints become tools, their annotations
|
|
242
253
|
and redactions, is the host's catalogue. The package generates, dispatches and
|
|
243
254
|
gates it.
|
|
244
255
|
- **`mcp:lint`, `mcp:parity`, `mcp:smoke`, `mcp:test-coverage`** — the remaining
|
|
245
|
-
|
|
256
|
+
the origin host MCP scripts. Only the two the reusable CI workflows shell out to moved
|
|
246
257
|
(12-23's scope).
|
|
247
258
|
- **Authorization codes as rows.** They are stateless signed blobs, so there is no
|
|
248
259
|
table and nothing to sweep — only the replay store (rule 8).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call carrying the caller's bearer token (permission passthrough). Also ships the OAuth 2.1 authorization server (./oauth, ./hono: register/authorize/token, JWKS and both .well-known documents), the package-owned Prisma partial + migration for its three tables, the mcp:generate/mcp:check (./generate) and mcp:coverage (./coverage) gates, and the reusable AI-connect onboarding UI (./react).",
|
|
6
6
|
"exports": {
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"prisma:sync:check": "node scripts/sync-mcp-schema.mjs --check"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@12-apps/onboarding": "^
|
|
27
|
-
"@12-apps/rbac": "^
|
|
28
|
-
"@12-apps/ui": "^
|
|
26
|
+
"@12-apps/onboarding": "^2.0.0",
|
|
27
|
+
"@12-apps/rbac": "^4.0.1",
|
|
28
|
+
"@12-apps/ui": "^5.0.0",
|
|
29
29
|
"@mui/icons-material": "^6.5.0",
|
|
30
30
|
"jose": "^6.1.3",
|
|
31
31
|
"react": "^19.2.0"
|
package/prisma/mcp.prisma
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Host-agnostic by design (the entity-lifecycle / rbac doctrine): `user_id` is a
|
|
13
13
|
// by-value scalar with NO relation, because this package cannot know the name of
|
|
14
|
-
// the host's user model. The host's own migration may add the FK (
|
|
14
|
+
// the host's user model. The host's own migration may add the FK (the origin host's
|
|
15
15
|
// is ON DELETE CASCADE). Note there is deliberately no `oauth_codes` table:
|
|
16
16
|
// authorization codes are STATELESS signed blobs, so there is nothing to store
|
|
17
17
|
// and nothing to sweep.
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
-- Authorization codes are deliberately absent: they are STATELESS signed blobs,
|
|
19
19
|
-- so there is no table to create and nothing to sweep.
|
|
20
20
|
--
|
|
21
|
-
-- The columns, defaults, indexes and CHECK are
|
|
21
|
+
-- The columns, defaults, indexes and CHECK are the origin host's
|
|
22
22
|
-- `20260713120000_add_oauth_client_refresh`,
|
|
23
23
|
-- `20260715180000_add_onboarding_state_mcp_connection` (the mcp_connections half
|
|
24
24
|
-- — the onboarding half belongs to @12-apps/onboarding) and
|
|
25
25
|
-- `20260720120000_add_mcp_connection_host` verbatim, minus the FK to `users`:
|
|
26
26
|
-- this package cannot know the name of a host's user table, and a host that has
|
|
27
|
-
-- one keeps its own constraint (
|
|
27
|
+
-- one keeps its own constraint (the origin host's is ON DELETE CASCADE).
|
|
28
28
|
--
|
|
29
29
|
-- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block for
|
|
30
30
|
-- the CHECK, which has no IF NOT EXISTS form). That is what makes adoption by a
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
-- the difference bites exactly the host this file is written for: `CREATE TABLE IF
|
|
36
36
|
-- NOT EXISTS` skips the whole table, columns included, so a host holding an OLDER
|
|
37
37
|
-- shape of one of these tables silently keeps it. Each table below is therefore
|
|
38
|
-
-- followed by a guarded `ADD COLUMN` for every column that reached
|
|
38
|
+
-- followed by a guarded `ADD COLUMN` for every column that reached the origin host in a
|
|
39
39
|
-- LATER migration than its own CREATE. The full audit: `oauth_refresh_tokens
|
|
40
40
|
-- .user_sub` (`20260713150000_add_oauth_refresh_user_sub`) and `mcp_connections
|
|
41
41
|
-- .host` (`20260720120000_add_mcp_connection_host`). `oauth_clients` needs none —
|
|
@@ -112,10 +112,10 @@ CREATE INDEX IF NOT EXISTS "oauth_refresh_tokens_user_email_client_id_idx"
|
|
|
112
112
|
-- `CREATE TABLE IF NOT EXISTS` skips the WHOLE table, so a host that already holds
|
|
113
113
|
-- `oauth_refresh_tokens` in an OLDER SHAPE gets none of the columns declared above
|
|
114
114
|
-- — statement-level guarding is not the same as column-level guarding. That is
|
|
115
|
-
-- precisely how
|
|
115
|
+
-- precisely how the origin host's own history ran: `user_sub` arrived in a SECOND
|
|
116
116
|
-- migration (FUT-105, `20260713150000_add_oauth_refresh_user_sub`), so a host
|
|
117
117
|
-- frozen before it would adopt this file, skip the CREATE, never get the column,
|
|
118
|
-
-- and then fail on every refresh the package serves. Mirror
|
|
118
|
+
-- and then fail on every refresh the package serves. Mirror the origin host's pair
|
|
119
119
|
-- verbatim — guarded add with a backfill default to satisfy NOT NULL, then drop
|
|
120
120
|
-- the default so the column matches the Prisma schema (`String`, no default).
|
|
121
121
|
-- Both statements are no-ops on a fresh host and on a replay.
|
|
@@ -147,6 +147,6 @@ CREATE INDEX IF NOT EXISTS "mcp_connections_last_active_at_idx"
|
|
|
147
147
|
ON "mcp_connections"("last_active_at");
|
|
148
148
|
|
|
149
149
|
-- A host adopting this migration where `mcp_connections` predates the `host`
|
|
150
|
-
-- column (
|
|
150
|
+
-- column (the origin host added it in a later migration) gets it here; a fresh host
|
|
151
151
|
-- already has it from the CREATE above, so the guard makes both cases a no-op.
|
|
152
152
|
ALTER TABLE "mcp_connections" ADD COLUMN IF NOT EXISTS "host" TEXT;
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Only the schema partial. MIGRATIONS ARE NOT HANDLED HERE — the host
|
|
16
16
|
* discovers and copies them structurally, by looking for a `prisma/migrations`
|
|
17
|
-
* directory inside every installed `@12-apps/*` package (see
|
|
17
|
+
* directory inside every installed `@12-apps/*` package (see the origin host's
|
|
18
18
|
* packages/prisma/scripts/sync-prisma-plugins.mjs).
|
|
19
19
|
*
|
|
20
20
|
* The host package that owns the schema folder MUST also declare this package
|
|
21
21
|
* as a dependency, so the source of the copy is present in every build
|
|
22
22
|
* context.
|
|
23
23
|
*
|
|
24
|
-
* Default host path follows the
|
|
24
|
+
* Default host path follows the origin host layout
|
|
25
25
|
* (`packages/prisma/prisma/schema/`); another repo passes its own schema
|
|
26
26
|
* folder as the positional argument, or sets MCP_HOST_SCHEMA_DIR.
|
|
27
27
|
*/
|
|
@@ -6,7 +6,7 @@ import { collectRouteMethods } from "./route-methods";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* `@12-apps/mcp/coverage` — the MCP route/action coverage gate (12-23), moved out
|
|
9
|
-
* of
|
|
9
|
+
* of the origin host's `apps/web/scripts/mcp/coverage.ts` so a host's own script is a
|
|
10
10
|
* one-line re-export and the CI workflow that shells out to the consumer's
|
|
11
11
|
* `mcp:coverage` package script (`12-apps/ci`'s `mcp-contract.yml`) keeps working
|
|
12
12
|
* unchanged.
|
|
@@ -11,7 +11,7 @@ import { exportedNamesOf, urlPathOf, walkRouteFiles } from "@12-apps/rbac/covera
|
|
|
11
11
|
* walk (`walkRouteFiles`), the URL mapping (`urlPathOf`) AND the export-head
|
|
12
12
|
* parser (`exportedNamesOf`) — and that is deliberate: both gates assert a
|
|
13
13
|
* COMPLETENESS property over the same two surfaces (`app/**` route files and
|
|
14
|
-
* `*actions.ts` modules), and
|
|
14
|
+
* `*actions.ts` modules), and the origin host's own comment on the shared scanner says
|
|
15
15
|
* why they must share it — "so the two gates can never disagree about what the
|
|
16
16
|
* surface is". Two copies would agree on the day they were written and drift
|
|
17
17
|
* silently after, in the direction of not looking. What is left here is the one
|
package/src/generate/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { ToolManifest } from "../types";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* `@12-apps/mcp/generate` — the `mcp:generate` / `mcp:check` gate (12-23), moved
|
|
16
|
-
* out of
|
|
16
|
+
* out of the origin host's `apps/web/scripts/mcp/generate.ts` so a host's own script is
|
|
17
17
|
* a one-line call and `12-apps/ci`'s `mcp-contract.yml`, which shells out to the
|
|
18
18
|
* consumer's `mcp:check` package script, keeps working unchanged.
|
|
19
19
|
*
|
|
@@ -4,7 +4,7 @@ import { issuer, resourceAudience, DEFAULT_MCP_RESOURCE_PATH, type McpScope } fr
|
|
|
4
4
|
import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* JWT access-token issuer + verifier (12-23, ported from
|
|
7
|
+
* JWT access-token issuer + verifier (12-23, ported from the origin host's
|
|
8
8
|
* `lib/mcp/oauth/jwt.ts` — behaviour unchanged; the signing key arrives through a
|
|
9
9
|
* provider and the resource path is config).
|
|
10
10
|
*
|
|
@@ -4,7 +4,7 @@ import { issuer } from "./config";
|
|
|
4
4
|
import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Stateless authorization-code mint/verify (12-23, ported from
|
|
7
|
+
* Stateless authorization-code mint/verify (12-23, ported from the origin host's
|
|
8
8
|
* `lib/mcp/oauth/authorization-code.ts` — behaviour unchanged; the signing key
|
|
9
9
|
* arrives through a provider instead of an env read).
|
|
10
10
|
*
|
package/src/oauth/authorize.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { StoredOAuthClient } from "./stores";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The OAuth 2.1 Authorization Code + PKCE authorization endpoint (12-23, ported
|
|
9
|
-
* from
|
|
9
|
+
* from the origin host's `app/api/oauth/authorize/route.ts`).
|
|
10
10
|
*
|
|
11
11
|
* It renders no UI: it authenticates the caller against the host's cookie session
|
|
12
12
|
* (through `resolveSession`), validates the request, and either 302-redirects an
|
package/src/oauth/clients.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Client registration and the open-redirect guard (12-23, ported from
|
|
11
|
-
*
|
|
11
|
+
* the origin host's `lib/mcp/oauth/clients.ts`).
|
|
12
12
|
*
|
|
13
13
|
* A registered client is an external host (a Claude.ai / ChatGPT connector) from
|
|
14
14
|
* RFC 7591 dynamic client registration, or a static registration an operator
|
|
@@ -122,7 +122,7 @@ export interface ProviderAttributionRule {
|
|
|
122
122
|
provider: string;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
125
|
+
/** The origin host's rules, and a sane default for any host talking to the same two. */
|
|
126
126
|
export const DEFAULT_PROVIDER_ROOTS: readonly ProviderAttributionRule[] = [
|
|
127
127
|
{ roots: ["claude.ai", "anthropic.com"], provider: "claude" },
|
|
128
128
|
{ roots: ["chatgpt.com", "openai.com"], provider: "chatgpt" },
|
package/src/oauth/code-replay.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Single-use guard for the stateless authorization codes (12-23, ported from
|
|
3
|
-
*
|
|
3
|
+
* the origin host's `lib/mcp/oauth/token-replay.ts`).
|
|
4
4
|
*
|
|
5
5
|
* A code is a signed blob with a `jti`, so "already redeemed" has to be remembered
|
|
6
6
|
* somewhere. The in-process option remembers it IN THIS PROCESS: a small map of
|
package/src/oauth/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The OAuth 2.1 authorization-server foundation: the shared scope source, the
|
|
3
3
|
* issuer/audience derivation, and the trusted-origin resolver every URL in the
|
|
4
|
-
* surface is built from (12-23, ported from
|
|
4
|
+
* surface is built from (12-23, ported from the origin host's
|
|
5
5
|
* `lib/mcp/oauth/config.ts`).
|
|
6
6
|
*
|
|
7
7
|
* Keeping the scopes and the origin resolution in ONE place is what stops the
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* What was env-reading in the host is CONFIG here (the package must not learn a
|
|
13
13
|
* host's variable names); `trustedOriginsFromEnv` is the one-line helper that
|
|
14
|
-
* keeps
|
|
14
|
+
* keeps the origin host's wiring identical.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** Scopes advertised by both discovery documents. `mcp:write` gates mutating tools. */
|
|
@@ -66,7 +66,7 @@ function normalizeOrigins(origins: readonly string[]): string[] {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Read a comma-separated allowlist out of an environment variable —
|
|
69
|
+
* Read a comma-separated allowlist out of an environment variable — the origin host
|
|
70
70
|
* passes `trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')`, so the behaviour
|
|
71
71
|
* is identical while the variable's NAME stays the host's.
|
|
72
72
|
*/
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { providerForHostId, type AiProvider } from "../guide";
|
|
2
|
+
import type {
|
|
3
|
+
McpConnectionStore,
|
|
4
|
+
RefreshTokenStore,
|
|
5
|
+
StoredMcpConnection,
|
|
6
|
+
} from "./stores";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The account surface's connection OPERATIONS (12-48) — the half of the
|
|
10
|
+
* `GET/DELETE /api/account/mcp-connections` endpoints that is contract rather
|
|
11
|
+
* than host vocabulary.
|
|
12
|
+
*
|
|
13
|
+
* The ROUTE stays in the host on purpose: it mixes the host's session
|
|
14
|
+
* resolution, its response envelope, its published plugin URLs and its logger,
|
|
15
|
+
* and injecting all four here would make the config surface bigger than the
|
|
16
|
+
* handler it replaces. What must NOT stay in each host is the disconnect's
|
|
17
|
+
* both-halves rule, because getting it half right LOOKS right:
|
|
18
|
+
*
|
|
19
|
+
* `connections.revokeByHost` ends the connection rows and returns the OAuth
|
|
20
|
+
* client ids behind them — and a host that stops there has revoked nothing that
|
|
21
|
+
* matters. The assistant still holds a live refresh token for each of those
|
|
22
|
+
* clients, rotates it on schedule, and the very next grant records fresh
|
|
23
|
+
* activity: the card the user just disconnected lights green again on its own.
|
|
24
|
+
* So the rule is one function: revoke the rows AND end every live refresh token
|
|
25
|
+
* of each returned client, in the same call, with no way to import one half
|
|
26
|
+
* without the other.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NOT invalidated here: the assistant's current ACCESS token.
|
|
29
|
+
* Those are self-contained JWTs the server does not track; a just-disconnected
|
|
30
|
+
* host keeps working for at most their TTL (15 minutes by default) and can then
|
|
31
|
+
* obtain nothing further.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** An active AI connection, narrowed for display. */
|
|
35
|
+
export interface AiConnectionSnapshot {
|
|
36
|
+
oauthClientId: string;
|
|
37
|
+
clientName: string | null;
|
|
38
|
+
/** The provider this connection is attributed to (`null` = pre-attribution). */
|
|
39
|
+
host: AiProvider | null;
|
|
40
|
+
connectedAt: Date;
|
|
41
|
+
lastActiveAt: Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The caller the operations act for — always the session's own user. */
|
|
45
|
+
export interface AiConnectionCaller {
|
|
46
|
+
/** The host's user id — what `mcp_connections` rows are keyed by. */
|
|
47
|
+
userId: string;
|
|
48
|
+
/** The identity refresh tokens are bound to (the AS binds by email). */
|
|
49
|
+
email: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What one disconnect actually ended, for the host's log and response. */
|
|
53
|
+
export interface AiDisconnectResult {
|
|
54
|
+
/** OAuth client ids whose connection rows were revoked. */
|
|
55
|
+
disconnectedClientIds: string[];
|
|
56
|
+
/** Live refresh tokens ended across those clients — the half that cuts access. */
|
|
57
|
+
revokedRefreshTokens: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow a stored `host` string to a known provider, or `null`. */
|
|
61
|
+
function asProvider(host: string | null): AiProvider | null {
|
|
62
|
+
return host === null ? null : providerForHostId(host);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A user's active connections, most-recently-active first, with the stored open
|
|
67
|
+
* `host` string narrowed to the package's closed {@link AiProvider} union — the
|
|
68
|
+
* store cannot know which assistants have screens, but the union is this
|
|
69
|
+
* package's own vocabulary (`guide.ts`), so the narrowing lives beside it
|
|
70
|
+
* rather than being re-derived in every host.
|
|
71
|
+
*/
|
|
72
|
+
export async function listAiConnections(
|
|
73
|
+
connections: McpConnectionStore,
|
|
74
|
+
userId: string,
|
|
75
|
+
): Promise<AiConnectionSnapshot[]> {
|
|
76
|
+
const rows: StoredMcpConnection[] = await connections.listActive(userId);
|
|
77
|
+
return rows.map((row) => ({ ...row, host: asProvider(row.host) }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Disconnect one provider for this user — BOTH halves, atomically from the
|
|
82
|
+
* caller's point of view (see the module doc for why one half alone is a
|
|
83
|
+
* disconnect that undoes itself).
|
|
84
|
+
*
|
|
85
|
+
* Idempotent: disconnecting a provider that was never connected returns zero
|
|
86
|
+
* counts rather than failing, so a double-click is harmless. Repeat calls also
|
|
87
|
+
* report zero — `revokeLiveForClient` skips already-revoked tokens by contract.
|
|
88
|
+
*/
|
|
89
|
+
export async function disconnectAiHost(
|
|
90
|
+
stores: { connections: McpConnectionStore; refreshTokens: RefreshTokenStore },
|
|
91
|
+
caller: AiConnectionCaller,
|
|
92
|
+
host: AiProvider,
|
|
93
|
+
): Promise<AiDisconnectResult> {
|
|
94
|
+
const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);
|
|
95
|
+
const revoked = await Promise.all(
|
|
96
|
+
disconnectedClientIds.map((clientId) =>
|
|
97
|
+
stores.refreshTokens.revokeLiveForClient(caller.email, clientId),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
disconnectedClientIds,
|
|
102
|
+
revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0),
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/oauth/context.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { McpOauthStores, StoredOAuthClient } from "./stores";
|
|
|
25
25
|
/** The identity an authorize request binds a code to. From the SESSION only. */
|
|
26
26
|
export interface McpOauthSession {
|
|
27
27
|
/**
|
|
28
|
-
* The OAuth subject (
|
|
28
|
+
* The OAuth subject (the origin host passes the Google `sub`, falling back to the
|
|
29
29
|
* email). Carried through every rotation so a refreshed token keeps the same
|
|
30
30
|
* stable `sub`.
|
|
31
31
|
*/
|
|
@@ -45,7 +45,7 @@ export interface McpOauthPaths {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export const DEFAULT_OAUTH_PATHS: McpOauthPaths = {
|
|
48
|
-
//
|
|
48
|
+
// the origin host's paths, and the ones the RFC 8414 document has always advertised.
|
|
49
49
|
authorize: "/api/oauth/authorize",
|
|
50
50
|
token: "/api/oauth/token",
|
|
51
51
|
register: "/api/oauth/register",
|
|
@@ -78,12 +78,12 @@ export interface McpOauthConfig {
|
|
|
78
78
|
resolveSession: (request: Request) => Promise<McpOauthSession | null> | McpOauthSession | null;
|
|
79
79
|
/**
|
|
80
80
|
* The operator gate. `false` makes the whole surface inert — authorize/token/jwks
|
|
81
|
-
* answer 404 and registration answers 403 — which is how
|
|
81
|
+
* answer 404 and registration answers 403 — which is how the origin host ships it OFF
|
|
82
82
|
* by default (`MCP_BEARER_ENABLED`). Default: enabled (mounting is the opt-in).
|
|
83
83
|
*/
|
|
84
84
|
enabled?: boolean | (() => boolean);
|
|
85
85
|
/**
|
|
86
|
-
* Signing material. Default: the env-backed provider with
|
|
86
|
+
* Signing material. Default: the env-backed provider with the origin host's variable
|
|
87
87
|
* names. `null` from the provider means "not provisioned": nothing is minted and
|
|
88
88
|
* the JWKS answers 503 rather than falling back to a weaker mode.
|
|
89
89
|
*/
|
|
@@ -216,7 +216,7 @@ export function resolveMcpOauthConfig(config: McpOauthConfig): McpOauthContext {
|
|
|
216
216
|
stores: config.stores,
|
|
217
217
|
resolveSession: config.resolveSession,
|
|
218
218
|
// Mounting is the opt-in, so the gate defaults to ON; a host that ships the
|
|
219
|
-
// surface dark passes its own flag (
|
|
219
|
+
// surface dark passes its own flag (the origin host: `MCP_BEARER_ENABLED`).
|
|
220
220
|
enabled: typeof enabled === "function" ? enabled : () => enabled,
|
|
221
221
|
// `null` from the provider means "not provisioned": nothing is minted and the
|
|
222
222
|
// JWKS answers 503 rather than falling back to a weaker mode.
|
package/src/oauth/index.ts
CHANGED
|
@@ -115,3 +115,10 @@ export type {
|
|
|
115
115
|
StoredRefreshToken,
|
|
116
116
|
TokenEndpointAuthMethod,
|
|
117
117
|
} from "./stores";
|
|
118
|
+
export {
|
|
119
|
+
disconnectAiHost,
|
|
120
|
+
listAiConnections,
|
|
121
|
+
type AiConnectionCaller,
|
|
122
|
+
type AiConnectionSnapshot,
|
|
123
|
+
type AiDisconnectResult,
|
|
124
|
+
} from "./connections";
|
package/src/oauth/keys.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { exportJWK, importPKCS8, type CryptoKey, type JWK } from "jose";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Signing-key / JWK loading for the OAuth authorization server (12-23, ported
|
|
5
|
-
* from
|
|
5
|
+
* from the origin host's `lib/mcp/oauth/keys.ts`).
|
|
6
6
|
*
|
|
7
7
|
* ES256 (P-256) from PEM material, the published public JWK (with `kid` for
|
|
8
8
|
* rotation), and a safe-by-default absence signal (`null`) when no key is
|
|
@@ -10,7 +10,7 @@ import { exportJWK, importPKCS8, type CryptoKey, type JWK } from "jose";
|
|
|
10
10
|
* rather than falling back to a weaker mode while the surface is mounted.
|
|
11
11
|
*
|
|
12
12
|
* WHERE the PEM comes from is the host's business: `loadSigningKeyFromEnv` keeps
|
|
13
|
-
*
|
|
13
|
+
* the origin host's env-var wiring, and any other provider (a secrets manager, a KMS
|
|
14
14
|
* export) satisfies the same `McpSigningKeyProvider` shape.
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -87,13 +87,13 @@ export function signingKeyProvider(
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/** Env var carrying the ES256 private key as a PKCS#8 PEM (
|
|
90
|
+
/** Env var carrying the ES256 private key as a PKCS#8 PEM (the origin host's name). */
|
|
91
91
|
export const DEFAULT_SIGNING_KEY_ENV = "MCP_OAUTH_SIGNING_KEY";
|
|
92
92
|
/** Env var carrying the key id (`kid`) used to select the key during rotation. */
|
|
93
93
|
export const DEFAULT_SIGNING_KEY_ID_ENV = "MCP_OAUTH_SIGNING_KEY_ID";
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* The env-backed provider —
|
|
96
|
+
* The env-backed provider — the origin host's wiring, kept identical, with the
|
|
97
97
|
* variable names as arguments so the package states no host's vocabulary.
|
|
98
98
|
*/
|
|
99
99
|
export function loadSigningKeyFromEnv(
|
package/src/oauth/pkce.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PKCE (RFC 7636) S256 challenge helpers for the OAuth authorization server
|
|
3
|
-
* (12-23, ported verbatim from
|
|
3
|
+
* (12-23, ported verbatim from the origin host's `lib/mcp/oauth/pkce.ts`).
|
|
4
4
|
*
|
|
5
5
|
* OAuth 2.1 mandates the `S256` code-challenge method and forbids `plain`, so
|
|
6
6
|
* this module computes `BASE64URL(SHA-256(code_verifier))` and compares it to
|
package/src/oauth/refresh.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import type { NewRefreshToken, RefreshTokenStore, StoredRefreshToken } from "./stores";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Refresh-token issue + rotation (12-23, ported from
|
|
6
|
+
* Refresh-token issue + rotation (12-23, ported from the origin host's
|
|
7
7
|
* `lib/mcp/oauth/refresh.ts` — behaviour unchanged; Prisma calls became the
|
|
8
8
|
* `RefreshTokenStore` port).
|
|
9
9
|
*
|
package/src/oauth/register.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { McpOauthContext } from "./context";
|
|
|
3
3
|
import type { TokenEndpointAuthMethod } from "./stores";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* RFC 7591 Dynamic Client Registration (12-23, ported from
|
|
6
|
+
* RFC 7591 Dynamic Client Registration (12-23, ported from the origin host's
|
|
7
7
|
* `app/api/oauth/register/route.ts`).
|
|
8
8
|
*
|
|
9
9
|
* An external host (a Claude.ai / ChatGPT connector) self-registers by POSTing RFC
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "./token-response";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* The two grant handlers of the token endpoint (12-23, ported from
|
|
22
|
+
* The two grant handlers of the token endpoint (12-23, ported from the origin host's
|
|
23
23
|
* `lib/mcp/oauth/token-grants.ts`).
|
|
24
24
|
*
|
|
25
25
|
* Security invariants enforced here, unchanged:
|
|
@@ -123,7 +123,7 @@ function readAuthorizationCodeParams(form: URLSearchParams): AuthorizationCodePa
|
|
|
123
123
|
/**
|
|
124
124
|
* Redeem a presented code, or refuse.
|
|
125
125
|
*
|
|
126
|
-
* The ORDER is the security contract, and it is the order
|
|
126
|
+
* The ORDER is the security contract, and it is the order the origin host established:
|
|
127
127
|
* verify the code's signature, authenticate the presenting client against the
|
|
128
128
|
* client the code was bound to, check the bound `redirect_uri`, check PKCE — and
|
|
129
129
|
* only THEN consume the single-use `jti`. Consuming earlier would let a failed
|
|
@@ -5,7 +5,7 @@ import type { OAuthClientStore } from "./stores";
|
|
|
5
5
|
/**
|
|
6
6
|
* The token endpoint's wire helpers: the RFC 6749 §5.1/§5.2 bodies and client
|
|
7
7
|
* authentication (12-23, split out of the grant handlers so each file stays under
|
|
8
|
-
* the size gate — the same split
|
|
8
|
+
* the size gate — the same split the origin host made).
|
|
9
9
|
*
|
|
10
10
|
* These bodies are NOT the house `{ data }` envelope, deliberately: they are read
|
|
11
11
|
* by OAuth clients that expect the RFC shapes at the top level, and `Cache-Control:
|
package/src/server/manifest.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { GeneratedTool, ToolManifest } from "../types";
|
|
|
11
11
|
export interface BuildManifestOptions {
|
|
12
12
|
/** Bumped intentionally on any tool-shape change (mirrors the golden catalog). */
|
|
13
13
|
version: number;
|
|
14
|
-
/** Human label for the spec, e.g. "
|
|
14
|
+
/** Human label for the spec, e.g. "acme web @ openapi.json". */
|
|
15
15
|
source: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -16,7 +16,7 @@ import type { GeneratedTool } from "../types";
|
|
|
16
16
|
* ask again: a tool that shipped stays invisible to every ALREADY CONNECTED
|
|
17
17
|
* client for as long as that connection lives.
|
|
18
18
|
*
|
|
19
|
-
* That is not a hypothetical. In
|
|
19
|
+
* That is not a hypothetical. In the origin host a new tool reached production,
|
|
20
20
|
* answered on its route, and did not appear in a live connector — behind a
|
|
21
21
|
* `serverInfo.version` frozen at its initial value while ~280 tools were added
|
|
22
22
|
* underneath it. Nothing was broken; the only thing asking anyone to bump it was
|