@12-apps/mcp 3.15.0 → 3.17.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 +41 -0
- package/README.md +1 -1
- package/dist/{chunk-VDD4YRNP.js → chunk-EANHJLDH.js} +13 -4
- package/dist/chunk-EANHJLDH.js.map +1 -0
- package/dist/chunk-F3LMK6OL.js +25 -0
- package/dist/chunk-F3LMK6OL.js.map +1 -0
- package/dist/{chunk-UIILEGAC.js → chunk-WCZC4TPX.js} +193 -48
- package/dist/chunk-WCZC4TPX.js.map +1 -0
- package/dist/{create-api-mcp-oauth-CsC0jlH7.d.ts → create-api-mcp-oauth-BEvYLRBV.d.ts} +96 -4
- package/dist/e2e/index.d.ts +109 -0
- package/dist/e2e/index.js +27 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.js +79 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/{guide-KQNcXlMG.d.ts → guide-CrzdsdNf.d.ts} +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/index.d.ts +102 -4
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/dist/{locales-eKE_OJw4.d.ts → locales-Cv0Pecvu.d.ts} +1 -1
- package/dist/manifest/index.d.ts +29 -7
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +1 -1
- package/dist/manifest/server.js +2 -2
- package/dist/oauth/index.d.ts +19 -4
- package/dist/oauth/index.js +4 -2
- package/dist/react/index.d.ts +3 -3
- package/features/ai-connect.feature +46 -0
- package/package.json +25 -8
- package/prisma/mcp.prisma +10 -0
- package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +136 -0
- package/src/e2e/world.ts +84 -0
- package/src/index.ts +11 -0
- package/src/manifest/index.ts +24 -7
- package/src/oauth/access-token.ts +72 -10
- package/src/oauth/context.ts +20 -0
- package/src/oauth/index.ts +2 -0
- package/src/oauth/prisma-stores.ts +16 -5
- package/src/oauth/refresh-lineage.ts +77 -0
- package/src/oauth/refresh.ts +169 -82
- package/src/oauth/rotation-grace.ts +216 -0
- package/src/oauth/stores.ts +45 -1
- package/src/oauth/token-grants.ts +4 -1
- package/src/server/auth-failure.ts +145 -0
- package/src/server/jsonrpc.ts +44 -5
- package/dist/chunk-UIILEGAC.js.map +0 -1
- package/dist/chunk-VDD4YRNP.js.map +0 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- @12-apps/mcp: the sealed successor that makes refresh rotation RETRYABLE.
|
|
2
|
+
--
|
|
3
|
+
-- `oauth_refresh_tokens.grace_seal` holds a token's own plaintext, encrypted
|
|
4
|
+
-- (AES-256-GCM) under a key derived by HKDF from the plaintext of the token it
|
|
5
|
+
-- was rotated FROM, with its grace deadline sealed inside the same blob.
|
|
6
|
+
--
|
|
7
|
+
-- WHY THE COLUMN EXISTS. Rotation-on-use with replay revocation cannot tell a
|
|
8
|
+
-- thief from a client that used one token twice for an innocent reason, and
|
|
9
|
+
-- there are two routine innocent reasons: a response lost to a proxy timeout,
|
|
10
|
+
-- and two of the client's own sessions refreshing at the same moment. Both were
|
|
11
|
+
-- punished as theft — the whole lineage revoked, including the successor just
|
|
12
|
+
-- handed to whoever won — which killed a live connection and sent a human back
|
|
13
|
+
-- through the full authorization flow. With this column, re-presenting a
|
|
14
|
+
-- just-consumed token inside the window returns THAT SAME successor instead, so
|
|
15
|
+
-- one successor is still all that ever exists and no second family is created.
|
|
16
|
+
--
|
|
17
|
+
-- WHY IT IS NOT A PLAINTEXT COLUMN. The key is never stored: it is derived from
|
|
18
|
+
-- the parent, which is itself only ever stored hashed. A dump of this table
|
|
19
|
+
-- therefore yields ciphertext and nothing that opens it, so the package's
|
|
20
|
+
-- "hashed, never plaintext" invariant is unchanged. The only party that can open
|
|
21
|
+
-- a seal is one presenting the parent — which is the party being served, and
|
|
22
|
+
-- which already held the token that mints the successor.
|
|
23
|
+
--
|
|
24
|
+
-- The seal is CLEARED whenever a token is consumed or revoked, and that is the
|
|
25
|
+
-- bound on what it costs: a seal opens only under the plaintext it was rotated
|
|
26
|
+
-- from, so spent seals left in place would chain — one historical plaintext plus
|
|
27
|
+
-- a copy of this table would walk forward to the live token offline, with no
|
|
28
|
+
-- server call and therefore no replay detection.
|
|
29
|
+
--
|
|
30
|
+
-- Nullable, but NOT optional: the package writes this field on every rotation,
|
|
31
|
+
-- so a deployment that raises the package version without applying this
|
|
32
|
+
-- migration gets a runtime failure on every refresh, not a quietly disabled
|
|
33
|
+
-- window. Apply it in the same change as the version raise. Guarded with
|
|
34
|
+
-- IF NOT EXISTS like every other statement this package ships, so a host that
|
|
35
|
+
-- already added the column adopts the migration as a no-op.
|
|
36
|
+
ALTER TABLE "oauth_refresh_tokens"
|
|
37
|
+
ADD COLUMN IF NOT EXISTS "grace_seal" TEXT;
|
package/src/e2e/globs.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where this package's journeys and their steps live, ON DISK, in the consumer's
|
|
6
|
+
* `node_modules`.
|
|
7
|
+
*
|
|
8
|
+
* `defineBddConfig` takes filesystem globs, not module specifiers — it hands
|
|
9
|
+
* them to a glob matcher, so `'@12-apps/mcp/features/**'` matches
|
|
10
|
+
* nothing and, worse, matches nothing SILENTLY: bddgen compiles the features it
|
|
11
|
+
* found, finds none, and the run is green with zero journeys.
|
|
12
|
+
*
|
|
13
|
+
* So the paths are RESOLVED here instead of written down by every consumer. A
|
|
14
|
+
* host that hard-codes `node_modules/@12-apps/mcp/...` is broken by
|
|
15
|
+
* pnpm's nested store, by a workspace link, and by this package's own layout
|
|
16
|
+
* changing; resolving from the package's own entry point is correct under all
|
|
17
|
+
* three.
|
|
18
|
+
*/
|
|
19
|
+
const require_ = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The package root, found from a file this package definitely exports.
|
|
23
|
+
* `./package.json` is exported precisely so this lookup needs no guess about
|
|
24
|
+
* directory depth.
|
|
25
|
+
*/
|
|
26
|
+
function packageRoot(): string {
|
|
27
|
+
return dirname(require_.resolve('@12-apps/mcp/package.json'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every packaged `.feature`, for `defineBddConfig({ features })`. */
|
|
31
|
+
export const mcpFeatures: string = join(packageRoot(), 'features/**/*.feature');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
|
|
35
|
+
* optional decoration.
|
|
36
|
+
*
|
|
37
|
+
* bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
|
|
38
|
+
* `outputDir`. Left unset it defaults to the config's own directory, so a
|
|
39
|
+
* feature living under `node_modules/...` compiles to a path Playwright then
|
|
40
|
+
* IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
|
|
41
|
+
*
|
|
42
|
+
* The result is the worst kind of green: bddgen reports the features compiled,
|
|
43
|
+
* Playwright collects zero specs from them, and the run passes with the whole
|
|
44
|
+
* packaged suite silently absent.
|
|
45
|
+
*
|
|
46
|
+
* A host running SEVERAL packaged suites gives `featuresRoot` the directory they
|
|
47
|
+
* all sit under, since bddgen takes exactly one — and that is safe to do,
|
|
48
|
+
* because a feature outside it is a hard exit from bddgen, never a quiet
|
|
49
|
+
* omission.
|
|
50
|
+
*/
|
|
51
|
+
export const mcpFeaturesRoot: string = join(packageRoot(), 'features');
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every packaged step definition, for `defineBddConfig({ steps })`.
|
|
55
|
+
*
|
|
56
|
+
* COMPILED JavaScript, and that is the reason this package has a build step at
|
|
57
|
+
* all while everything else it exports is raw `.ts` through the `exports` map.
|
|
58
|
+
* Those entries are consumed by an application's BUNDLER, which transpiles
|
|
59
|
+
* whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
|
|
60
|
+
* imports this module, and bddgen imports the step files — and Node refuses to
|
|
61
|
+
* strip types from anything under `node_modules`
|
|
62
|
+
* (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
|
|
63
|
+
* does not rescue them either; it skips `node_modules` by design.
|
|
64
|
+
*
|
|
65
|
+
* The host's own steps glob must ALSO be listed — that is where its
|
|
66
|
+
* `defineMcpConnectWorld` call lives, and playwright-bdd imports every step
|
|
67
|
+
* file before any scenario runs, which is what makes the registration land in
|
|
68
|
+
* time in every worker.
|
|
69
|
+
*/
|
|
70
|
+
export const mcpSteps: string = join(packageRoot(), 'dist/e2e/steps/**/*.js');
|
package/src/e2e/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/mcp/e2e` — the packaged AI-connect journeys and the port a host
|
|
3
|
+
* implements to run them.
|
|
4
|
+
*
|
|
5
|
+
* The `e2e` capability the manifest declares. A host adds the three globs to
|
|
6
|
+
* its bdd config and calls `defineMcpConnectWorld` from inside its own steps
|
|
7
|
+
* glob; every scenario this package ships then runs in that host, including the
|
|
8
|
+
* ones added after it integrated. Nothing is copied, so nothing can rot.
|
|
9
|
+
*/
|
|
10
|
+
export { mcpFeatures, mcpFeaturesRoot, mcpSteps } from './globs.js';
|
|
11
|
+
export {
|
|
12
|
+
defineMcpConnectWorld,
|
|
13
|
+
mcpConnectWorld,
|
|
14
|
+
type McpConnectFixtures,
|
|
15
|
+
type McpConnectWorld,
|
|
16
|
+
} from './world.js';
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { expect, type Page } from '@playwright/test';
|
|
2
|
+
import { createBdd } from 'playwright-bdd';
|
|
3
|
+
|
|
4
|
+
import { mcpConnectWorld } from '../world.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The packaged AI-connect journeys' step definitions.
|
|
8
|
+
*
|
|
9
|
+
* Every locator is a test id THIS package's own components render —
|
|
10
|
+
* `ai-landing`, `ai-host-select`, `ai-copy-step`, `ai-configure-step`,
|
|
11
|
+
* `ai-connect-step`, `ai-confirm-waiting`, `mcp-endpoint-url` — which is what
|
|
12
|
+
* makes the scenarios portable: they mean the same thing in any app mounting
|
|
13
|
+
* `AiIntegrationOnboarding`. Everything a host owns (sign-in, where the flow is
|
|
14
|
+
* mounted, which assistants it offers) arrives through the world port.
|
|
15
|
+
*
|
|
16
|
+
* ## Why `package.json` declares this file under `sideEffects`
|
|
17
|
+
*
|
|
18
|
+
* Every Given/When/Then below runs at IMPORT time — registering a step is the
|
|
19
|
+
* whole point of the module, and it exports nothing anybody imports by name. To
|
|
20
|
+
* a bundler doing tree-shaking that reads as dead weight, and dropping it is
|
|
21
|
+
* licensed: the result is a suite where bddgen reports the features compiled
|
|
22
|
+
* and every scenario then fails on an undefined step.
|
|
23
|
+
*
|
|
24
|
+
* Not one step asserts a SENTENCE. The assistants' names, the button words and
|
|
25
|
+
* the permission prose are REQUIRED host config (FUT-760) — this package ships
|
|
26
|
+
* no assistant and no copy of its own — so a spec written against them could
|
|
27
|
+
* only ever have run in one adopter.
|
|
28
|
+
*/
|
|
29
|
+
const { Given, When, Then } = createBdd();
|
|
30
|
+
|
|
31
|
+
Given('I am signed in as somebody who may connect an assistant', async ({ page }) => {
|
|
32
|
+
await mcpConnectWorld().signInAsOwner(page);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
When('I open the AI integration screen', async ({ page }) => {
|
|
36
|
+
await mcpConnectWorld().openAiIntegrationScreen(page);
|
|
37
|
+
await expect(page.getByTestId('ai-onboarding')).toBeVisible();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
Then('the landing explains the permission model before anything is connected', async ({ page }) => {
|
|
41
|
+
await expect(page.getByTestId('ai-landing')).toBeVisible();
|
|
42
|
+
// The callout is the point of the landing: an owner is about to hand a third
|
|
43
|
+
// party a key to their store, and this is the screen that says what the key
|
|
44
|
+
// opens. It renders BEFORE any assistant is chosen, which is the only moment
|
|
45
|
+
// the answer is still "nothing".
|
|
46
|
+
await expect(page.getByTestId('ai-permission-callout')).toBeVisible();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
Then('it shows examples of what an assistant can be asked', async ({ page }) => {
|
|
50
|
+
await expect(page.getByTestId('ai-capability-examples')).toBeVisible();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
When('I start the walkthrough', async ({ page }) => {
|
|
54
|
+
await page.getByTestId('ai-landing-start').click();
|
|
55
|
+
await expect(page.getByTestId('ai-host-select')).toBeVisible();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
When('I choose an assistant that has no one-click install', async ({ page }) => {
|
|
59
|
+
// The flow BRANCHES on the guide's `pluginUrl`: with one it becomes
|
|
60
|
+
// Escolher → Instalar → Confirmar and there is no endpoint to copy at all.
|
|
61
|
+
// The host names a guide that takes the manual path, because which
|
|
62
|
+
// assistants exist is its configuration and not this package's.
|
|
63
|
+
await page.getByTestId(`ai-host-card-${mcpConnectWorld().fixtures.manualHostId}`).click();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
Then("I am asked to copy the store's endpoint", async ({ page }) => {
|
|
67
|
+
await expect(page.getByTestId('ai-copy-step')).toBeVisible();
|
|
68
|
+
await expect(page.getByTestId('mcp-endpoint-url')).toContainText(
|
|
69
|
+
mcpConnectWorld().fixtures.endpointUrl,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Copying is the ACTION that advances this step — there is no next button. */
|
|
74
|
+
async function copyEndpoint(page: Page): Promise<void> {
|
|
75
|
+
await page.getByTestId('ai-copy-endpoint').click();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open the assistant's connector page — the one action that unlocks "next".
|
|
80
|
+
*
|
|
81
|
+
* By its OWN id, not `getByRole('link')`: this control is a Button calling
|
|
82
|
+
* `window.open`, and the only real `<a>` in the step is the DOCS link, which
|
|
83
|
+
* deliberately does not unlock anything. A role query would have taken the
|
|
84
|
+
* docs link and then failed on an assertion about the button.
|
|
85
|
+
*/
|
|
86
|
+
async function openConnectorPage(page: Page): Promise<void> {
|
|
87
|
+
await page.getByTestId(`ai-host-link-${mcpConnectWorld().fixtures.manualHostId}`).click();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
When('I copy the endpoint', async ({ page }) => {
|
|
91
|
+
await copyEndpoint(page);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
Then('the walkthrough has moved on to configuring the connector', async ({ page }) => {
|
|
95
|
+
// The claim is that COPYING advanced it. The copy step offers no "next", so
|
|
96
|
+
// reaching the configure step at all proves the copy handler drove the
|
|
97
|
+
// wizard rather than some button the operator happened to press.
|
|
98
|
+
await expect(page.getByTestId('ai-configure-step')).toBeVisible();
|
|
99
|
+
await expect(page.getByTestId('ai-copy-step')).toHaveCount(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
Then('continuing is refused until I open the connector page', async ({ page }) => {
|
|
103
|
+
// The guard that stops an owner walking past the one step that does the
|
|
104
|
+
// actual work: the connector page has to be opened before "next" unlocks.
|
|
105
|
+
await expect(page.getByTestId('ai-configure-next')).toBeDisabled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
Then('once opened, continuing reaches the connect step', async ({ page }) => {
|
|
109
|
+
await openConnectorPage(page);
|
|
110
|
+
await expect(page.getByTestId('ai-configure-next')).toBeEnabled();
|
|
111
|
+
await page.getByTestId('ai-configure-next').click();
|
|
112
|
+
await expect(page.getByTestId('ai-connect-step')).toBeVisible();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
When('I go back a step', async ({ page }) => {
|
|
116
|
+
await page.getByTestId('ai-step-back').click();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
When('I work through configuring and connecting', async ({ page }) => {
|
|
120
|
+
await openConnectorPage(page);
|
|
121
|
+
await page.getByTestId('ai-configure-next').click();
|
|
122
|
+
await expect(page.getByTestId('ai-connect-step')).toBeVisible();
|
|
123
|
+
await page.getByTestId('ai-connect-done').click();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
Then('the confirmation is still waiting for the assistant', async ({ page }) => {
|
|
127
|
+
// Waiting rather than connected, because nothing has actually connected: the
|
|
128
|
+
// wizard reaching its last step is not evidence of a live connection, and
|
|
129
|
+
// this is the step that refuses to claim otherwise.
|
|
130
|
+
await expect(page.getByTestId('ai-confirm-waiting')).toBeVisible();
|
|
131
|
+
await expect(page.getByTestId('ai-confirm-connected')).toHaveCount(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
Then('it offers to test the connection again', async ({ page }) => {
|
|
135
|
+
await expect(page.getByTestId('ai-confirm-retest')).toBeVisible();
|
|
136
|
+
});
|
package/src/e2e/world.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The port a HOST implements to run the packaged AI-connect journeys.
|
|
5
|
+
*
|
|
6
|
+
* These journeys were written because the walkthrough they cover was tested
|
|
7
|
+
* NOWHERE. `./react` ships the whole flow — the landing, the assistant picker,
|
|
8
|
+
* the endpoint to copy, the configure and connect steps, the confirmation and
|
|
9
|
+
* its re-test — and twenty test ids go with it. Not one of them appeared in
|
|
10
|
+
* this package's own suite, and not one appeared in the origin host's specs
|
|
11
|
+
* either: that host's `ai.e2e.ts` drives its OWN plan lock and upsell modal,
|
|
12
|
+
* reaching only `ai-onboarding` and the status board on the way past.
|
|
13
|
+
*
|
|
14
|
+
* So the flow a store owner actually walks was covered by nothing, in either
|
|
15
|
+
* repo. That is the gap the `e2e` capability exists to convert into a
|
|
16
|
+
* declaration rather than an omission nobody can see.
|
|
17
|
+
*
|
|
18
|
+
* What stays the host's: how it signs an owner in, where it mounts the flow,
|
|
19
|
+
* and which assistant its `hosts` config offers — the guides are REQUIRED
|
|
20
|
+
* config (FUT-760), so the package has no assistant of its own to name.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Facts about the host that the assertions have to name. */
|
|
24
|
+
export interface McpConnectFixtures {
|
|
25
|
+
/**
|
|
26
|
+
* The id of an assistant the host offers whose guide has NO `pluginUrl`.
|
|
27
|
+
*
|
|
28
|
+
* The flow BRANCHES on that field: a guide carrying one gets `InstallStep`
|
|
29
|
+
* (a single "open the install link" button), and a guide without one gets
|
|
30
|
+
* the three-step manual path — copy the endpoint, configure, connect. These
|
|
31
|
+
* journeys walk the MANUAL path, so the host has to point at a guide that
|
|
32
|
+
* takes it. Naming the branch here rather than guessing keeps the scenario
|
|
33
|
+
* from failing in a host whose first assistant happens to ship a plugin.
|
|
34
|
+
*/
|
|
35
|
+
manualHostId: string;
|
|
36
|
+
/** The endpoint URL that host serves, as it is rendered for copying. */
|
|
37
|
+
endpointUrl: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a host must be able to do for these journeys to run in it. */
|
|
41
|
+
export interface McpConnectWorld {
|
|
42
|
+
/**
|
|
43
|
+
* Put the browser in a known signed-in state as somebody who may connect an
|
|
44
|
+
* assistant, with the flow's progress RESET to its first run.
|
|
45
|
+
*
|
|
46
|
+
* The reset is load-bearing rather than hygiene: the wizard persists its step
|
|
47
|
+
* through `@12-apps/onboarding`, so a scenario that advanced it would hand
|
|
48
|
+
* the next one a flow resuming from the middle — and the landing step, which
|
|
49
|
+
* two of these scenarios assert, would never render.
|
|
50
|
+
*/
|
|
51
|
+
signInAsOwner(page: Page): Promise<void>;
|
|
52
|
+
/** Land on the screen that mounts `AiIntegrationOnboarding`. */
|
|
53
|
+
openAiIntegrationScreen(page: Page): Promise<void>;
|
|
54
|
+
fixtures: McpConnectFixtures;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let installed: McpConnectWorld | null = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Install the host's implementation. Call this from a module inside the host's
|
|
61
|
+
* OWN steps glob — playwright-bdd imports every step file before any scenario
|
|
62
|
+
* runs, so a top-level call there is registered in time, in every worker.
|
|
63
|
+
*/
|
|
64
|
+
export function defineMcpConnectWorld(world: McpConnectWorld): void {
|
|
65
|
+
installed = world;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The installed world, or a refusal naming the fix.
|
|
70
|
+
*
|
|
71
|
+
* Throws rather than returning null: a step that ran against an absent world
|
|
72
|
+
* would fail on whatever it touched next, somewhere unrelated to the actual
|
|
73
|
+
* mistake, which is a diagnosis nobody should have to make twice.
|
|
74
|
+
*/
|
|
75
|
+
export function mcpConnectWorld(): McpConnectWorld {
|
|
76
|
+
if (!installed) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'No mcp e2e world is installed. Call defineMcpConnectWorld({ … }) from a module inside ' +
|
|
79
|
+
"your own `steps` glob — playwright-bdd imports those before any scenario runs, " +
|
|
80
|
+
'which is what makes the registration land in every worker.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return installed;
|
|
84
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,17 @@ export {
|
|
|
73
73
|
type McpJsonRpcOptions,
|
|
74
74
|
type McpServerInfo,
|
|
75
75
|
} from "./server/jsonrpc";
|
|
76
|
+
// WHY a call was refused, and what a client should do about it. The RFC gives a
|
|
77
|
+
// resource server three challenge codes, which is not enough to tell a lapsed
|
|
78
|
+
// connection from a misconfigured deployment — so the reason travels alongside.
|
|
79
|
+
export {
|
|
80
|
+
authFailureData,
|
|
81
|
+
describeAuthFailure,
|
|
82
|
+
type McpAuthFailure,
|
|
83
|
+
type McpAuthFailureData,
|
|
84
|
+
type McpAuthFailureReason,
|
|
85
|
+
type McpAuthRecovery,
|
|
86
|
+
} from "./server/auth-failure";
|
|
76
87
|
export {
|
|
77
88
|
buildManifest,
|
|
78
89
|
serializeManifest,
|
package/src/manifest/index.ts
CHANGED
|
@@ -53,13 +53,29 @@
|
|
|
53
53
|
* three hosts pass differently, which is the opposite of what a surface
|
|
54
54
|
* contribution is for. When the flow grows a real bound surface, the
|
|
55
55
|
* inventory grows with it.
|
|
56
|
-
* - **No `env`.**
|
|
57
|
-
* `
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
56
|
+
* - **No `env`.** Three helpers here DO read `process.env`
|
|
57
|
+
* (`trustedOriginsFromEnv`, and `loadSigningKeyFromEnv` for the key and its
|
|
58
|
+
* id) — this used to say "the package reads nothing itself", which was
|
|
59
|
+
* simply untrue and is the kind of sentence that makes a narrowing unfalsifiable.
|
|
60
|
+
* The accurate reason is narrower and still holds: nothing is read at import
|
|
61
|
+
* time or unconditionally, so a host that never calls these helpers has no
|
|
62
|
+
* environment dependency on this package at all; and where they ARE called,
|
|
63
|
+
* the variable's NAME is the caller's — passed as an argument by
|
|
64
|
+
* `trustedOriginsFromEnv`, and defaulted but overridable per call by
|
|
65
|
+
* `loadSigningKeyFromEnv`. `env` declares variables a host must answer for.
|
|
66
|
+
* Declaring these would oblige every host to answer for names it may
|
|
67
|
+
* legitimately have spelled differently, or never reads at all.
|
|
68
|
+
* - **`e2e` IS declared** (`../e2e`). It was narrowed away with "this package
|
|
69
|
+
* packages no journeys", which was true as a statement of fact and circular
|
|
70
|
+
* as an argument: it packaged none because nobody had written any, while
|
|
71
|
+
* `./react` shipped the entire AI-connect walkthrough — landing, assistant
|
|
72
|
+
* picker, endpoint copy, configure, connect, confirm — behind twenty test
|
|
73
|
+
* ids that NO suite touched. Not this package's (its two React tests cover
|
|
74
|
+
* the status board and the step primitives), and not the origin host's,
|
|
75
|
+
* whose `ai.e2e.ts` drives its own plan lock and upsell modal and only
|
|
76
|
+
* passes `ai-onboarding` on the way. A flow every adopter's owner has to
|
|
77
|
+
* walk was covered in neither repo, which is exactly the omission this
|
|
78
|
+
* capability exists to turn into a declaration.
|
|
63
79
|
* - **No `jobs`.** Nothing here sweeps: authorization codes are stateless
|
|
64
80
|
* signed blobs (the partial's header says so — there is no `oauth_codes`
|
|
65
81
|
* table and nothing to expire), and refresh-token revocation happens on
|
|
@@ -86,4 +102,5 @@ export const mcpManifest = {
|
|
|
86
102
|
*/
|
|
87
103
|
observability: { namespace: "mcp" },
|
|
88
104
|
server: ["http"],
|
|
105
|
+
e2e: { entry: "@12-apps/mcp/e2e", world: { factory: "defineMcpConnectWorld" } },
|
|
89
106
|
} as const satisfies PackageManifest;
|
|
@@ -35,14 +35,48 @@ export interface VerifiedAccessToken {
|
|
|
35
35
|
/** Distinct verification failure reasons the caller maps to OAuth challenges. */
|
|
36
36
|
export type AccessTokenErrorCode = "invalid_token" | "insufficient_scope";
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* WHY verification failed, at the granularity an operator and an agent can act on.
|
|
40
|
+
*
|
|
41
|
+
* `code` above is the RFC 6750 challenge and there are only three of those, so it
|
|
42
|
+
* cannot tell "your connection lapsed, refresh it" from "this token is not for
|
|
43
|
+
* this server". That distinction is the whole difference between an assistant
|
|
44
|
+
* that tells its user to reconnect this server and one that reports a generic
|
|
45
|
+
* failure on every tool call, so it is carried alongside rather than folded
|
|
46
|
+
* into `code`.
|
|
47
|
+
*
|
|
48
|
+
* `unverified` stays deliberately COARSE. Signature, issuer and audience collapse
|
|
49
|
+
* into it because naming which one failed is an oracle for the next attempt.
|
|
50
|
+
* Expiry is the documented exception — RFC 6750 names it in `error_description`
|
|
51
|
+
* precisely because a client must be told to refresh — and it leaks nothing: a
|
|
52
|
+
* token's `exp` is readable by whoever holds the token.
|
|
53
|
+
*/
|
|
54
|
+
export type AccessTokenFailureReason =
|
|
55
|
+
/** Valid in every other respect, but `exp` has passed. Refresh, do not re-consent. */
|
|
56
|
+
| "expired"
|
|
57
|
+
/** Signature, issuer or audience did not hold. Deliberately not narrowed further. */
|
|
58
|
+
| "unverified"
|
|
59
|
+
/** Verified, but missing the `sub`/`email` the identity is built from. */
|
|
60
|
+
| "incomplete"
|
|
61
|
+
/** No signing key is provisioned, so nothing can verify. An operator problem. */
|
|
62
|
+
| "not_provisioned"
|
|
63
|
+
/** A valid token that simply lacks the scope this call needs. */
|
|
64
|
+
| "insufficient_scope";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A typed verification failure — `code` drives the `WWW-Authenticate` challenge,
|
|
68
|
+
* {@link AccessTokenError.reason} drives what the caller is actually told.
|
|
69
|
+
*/
|
|
39
70
|
export class AccessTokenError extends Error {
|
|
40
71
|
readonly code: AccessTokenErrorCode;
|
|
41
72
|
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
readonly reason: AccessTokenFailureReason;
|
|
74
|
+
|
|
75
|
+
constructor(code: AccessTokenErrorCode, reason: AccessTokenFailureReason, message?: string) {
|
|
76
|
+
super(message ?? reason);
|
|
44
77
|
this.name = "AccessTokenError";
|
|
45
78
|
this.code = code;
|
|
79
|
+
this.reason = reason;
|
|
46
80
|
}
|
|
47
81
|
}
|
|
48
82
|
|
|
@@ -131,12 +165,30 @@ function parseScopes(scope: unknown): string[] {
|
|
|
131
165
|
* signature / wrong issuer / wrong audience / expired / malformed / unconfigured
|
|
132
166
|
* key) from `insufficient_scope` (a valid token lacking the required scope).
|
|
133
167
|
*/
|
|
168
|
+
/** jose's code for a token that parsed and verified but whose `exp` has passed. */
|
|
169
|
+
const JWT_EXPIRED_CODE = "ERR_JWT_EXPIRED";
|
|
170
|
+
|
|
171
|
+
/** Whether a thrown value is jose's expiry error, by its stable `code`. */
|
|
172
|
+
function isExpiry(error: unknown): boolean {
|
|
173
|
+
return (
|
|
174
|
+
typeof error === "object" &&
|
|
175
|
+
error !== null &&
|
|
176
|
+
(error as { code?: unknown }).code === JWT_EXPIRED_CODE
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
134
180
|
/**
|
|
135
181
|
* The cryptographic half: signature, `iss`, `aud`, `exp`.
|
|
136
182
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
183
|
+
* Bad signature, wrong issuer, wrong audience, malformed token and unknown key
|
|
184
|
+
* all collapse into ONE opaque `unverified`. A message naming the failed claim
|
|
185
|
+
* would be an oracle for the next attempt.
|
|
186
|
+
*
|
|
187
|
+
* EXPIRY is separated out, and only expiry. It is the one failure a
|
|
188
|
+
* well-behaved client is supposed to act on — refresh and retry — and it is the
|
|
189
|
+
* one the RFC gives a description for, so collapsing it left every lapsed
|
|
190
|
+
* connection indistinguishable from a broken one. It is not an oracle either:
|
|
191
|
+
* `exp` is a readable claim of a token the caller already holds.
|
|
140
192
|
*/
|
|
141
193
|
async function verifiedPayload(
|
|
142
194
|
loadSigningKey: McpSigningKeyProvider,
|
|
@@ -145,7 +197,9 @@ async function verifiedPayload(
|
|
|
145
197
|
): Promise<JWTPayload> {
|
|
146
198
|
const key = await loadSigningKey();
|
|
147
199
|
// No signing key configured → nothing can verify (safe-by-default).
|
|
148
|
-
if (!key)
|
|
200
|
+
if (!key) {
|
|
201
|
+
throw new AccessTokenError("invalid_token", "not_provisioned", "no signing key configured");
|
|
202
|
+
}
|
|
149
203
|
|
|
150
204
|
try {
|
|
151
205
|
const { payload } = await jwtVerify(token, await importJWK(key.publicJwk, SIGNING_ALG), {
|
|
@@ -156,8 +210,11 @@ async function verifiedPayload(
|
|
|
156
210
|
currentDate: options.now === undefined ? undefined : new Date(options.now),
|
|
157
211
|
});
|
|
158
212
|
return payload;
|
|
159
|
-
} catch {
|
|
160
|
-
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (isExpiry(error)) {
|
|
215
|
+
throw new AccessTokenError("invalid_token", "expired", "access token expired");
|
|
216
|
+
}
|
|
217
|
+
throw new AccessTokenError("invalid_token", "unverified", "token verification failed");
|
|
161
218
|
}
|
|
162
219
|
}
|
|
163
220
|
|
|
@@ -171,12 +228,17 @@ export async function verifyAccessToken(
|
|
|
171
228
|
const email = typeof payload.email === "string" ? payload.email : null;
|
|
172
229
|
const subject = typeof payload.sub === "string" ? payload.sub : null;
|
|
173
230
|
if (!email || !subject) {
|
|
174
|
-
throw new AccessTokenError(
|
|
231
|
+
throw new AccessTokenError(
|
|
232
|
+
"invalid_token",
|
|
233
|
+
"incomplete",
|
|
234
|
+
"missing subject or email claim",
|
|
235
|
+
);
|
|
175
236
|
}
|
|
176
237
|
|
|
177
238
|
const scopes = parseScopes(payload.scope);
|
|
178
239
|
if (options.requiredScope && !scopes.includes(options.requiredScope)) {
|
|
179
240
|
throw new AccessTokenError(
|
|
241
|
+
"insufficient_scope",
|
|
180
242
|
"insufficient_scope",
|
|
181
243
|
`token lacks required scope '${options.requiredScope}'`,
|
|
182
244
|
);
|
package/src/oauth/context.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./code-replay";
|
|
10
10
|
import { ACCESS_TOKEN_TTL_SECONDS } from "./access-token";
|
|
11
11
|
import { REFRESH_TOKEN_TTL_MS } from "./refresh";
|
|
12
|
+
import { DEFAULT_ROTATION_GRACE_MS } from "./rotation-grace";
|
|
12
13
|
import { loadSigningKeyFromEnv, type McpSigningKeyProvider } from "./keys";
|
|
13
14
|
import type { ProviderAttributionRule } from "./clients";
|
|
14
15
|
import type { McpOauthStores, StoredOAuthClient } from "./stores";
|
|
@@ -109,6 +110,22 @@ export interface McpOauthConfig {
|
|
|
109
110
|
loginCallbackParam?: string;
|
|
110
111
|
accessTokenTtlSeconds?: number;
|
|
111
112
|
refreshTokenTtlMs?: number;
|
|
113
|
+
/**
|
|
114
|
+
* How long a just-rotated refresh token keeps answering with the successor it
|
|
115
|
+
* minted, instead of being treated as a replay. Default
|
|
116
|
+
* {@link DEFAULT_ROTATION_GRACE_MS}; `0` restores the strict single-use rule.
|
|
117
|
+
*
|
|
118
|
+
* It exists because one client using one token twice is routine — a response
|
|
119
|
+
* lost to a proxy timeout, or two of its own sessions refreshing at once — and
|
|
120
|
+
* the strict rule cannot tell either from theft, so it revoked the lineage and
|
|
121
|
+
* cost a connected user their session. Inside the window the retry is answered
|
|
122
|
+
* with the SAME successor, so no second family is ever created. It does NOT
|
|
123
|
+
* merely defer detection by one rotation: two parties left holding one
|
|
124
|
+
* successor take the retry path again at every rotation, so a collision is
|
|
125
|
+
* detected only once two uses fall more than this window apart. That trade is
|
|
126
|
+
* argued in full in `./rotation-grace.ts`.
|
|
127
|
+
*/
|
|
128
|
+
refreshRotationGraceMs?: number;
|
|
112
129
|
/**
|
|
113
130
|
* The single-use guard for authorization codes — REQUIRED, and required on
|
|
114
131
|
* purpose. Pass a shared atomic store, or the literal `'in-process'` to accept
|
|
@@ -169,6 +186,7 @@ export interface McpOauthContext {
|
|
|
169
186
|
loginCallbackParam: string;
|
|
170
187
|
accessTokenTtlSeconds: number;
|
|
171
188
|
refreshTokenTtlMs: number;
|
|
189
|
+
refreshRotationGraceMs: number;
|
|
172
190
|
codeReplay: CodeReplayStore;
|
|
173
191
|
/**
|
|
174
192
|
* The resolved consent decision for one authorize request. Always present: with
|
|
@@ -197,6 +215,7 @@ function resolveSurface(
|
|
|
197
215
|
| "loginCallbackParam"
|
|
198
216
|
| "accessTokenTtlSeconds"
|
|
199
217
|
| "refreshTokenTtlMs"
|
|
218
|
+
| "refreshRotationGraceMs"
|
|
200
219
|
> {
|
|
201
220
|
return {
|
|
202
221
|
scopes: config.scopes ?? [...MCP_SUPPORTED_SCOPES],
|
|
@@ -206,6 +225,7 @@ function resolveSurface(
|
|
|
206
225
|
loginCallbackParam: config.loginCallbackParam ?? "callbackUrl",
|
|
207
226
|
accessTokenTtlSeconds: config.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
208
227
|
refreshTokenTtlMs: config.refreshTokenTtlMs ?? REFRESH_TOKEN_TTL_MS,
|
|
228
|
+
refreshRotationGraceMs: config.refreshRotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS,
|
|
209
229
|
};
|
|
210
230
|
}
|
|
211
231
|
|
package/src/oauth/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
signAccessToken,
|
|
41
41
|
verifyAccessToken,
|
|
42
42
|
type AccessTokenErrorCode,
|
|
43
|
+
type AccessTokenFailureReason,
|
|
43
44
|
type SignAccessTokenInput,
|
|
44
45
|
type VerifiedAccessToken,
|
|
45
46
|
type VerifyAccessTokenOptions,
|
|
@@ -94,6 +95,7 @@ export {
|
|
|
94
95
|
type RefreshTokenErrorCode,
|
|
95
96
|
type RefreshTokenIdentity,
|
|
96
97
|
} from "./refresh";
|
|
98
|
+
export { DEFAULT_ROTATION_GRACE_MS } from "./rotation-grace";
|
|
97
99
|
export {
|
|
98
100
|
inProcessCodeReplayStore,
|
|
99
101
|
type CodeReplayStore,
|
|
@@ -50,7 +50,9 @@ export interface McpOauthPrisma {
|
|
|
50
50
|
where:
|
|
51
51
|
| { tokenHash: { in: string[] } }
|
|
52
52
|
| { userEmail: string; clientId: string; revokedAt: null };
|
|
53
|
-
|
|
53
|
+
// `graceSeal` rides on every revoke: a revoked row must not keep an
|
|
54
|
+
// openable seal behind it (see `RefreshTokenStore.revokeHashes`).
|
|
55
|
+
data: { revokedAt: Date; graceSeal?: null };
|
|
54
56
|
}): Promise<{ count: number }>;
|
|
55
57
|
};
|
|
56
58
|
mcpConnection: {
|
|
@@ -98,7 +100,7 @@ interface McpOauthTx {
|
|
|
98
100
|
create(args: { data: NewRefreshToken }): Promise<unknown>;
|
|
99
101
|
updateMany(args: {
|
|
100
102
|
where: { tokenHash: string; revokedAt: null };
|
|
101
|
-
data: { revokedAt: Date };
|
|
103
|
+
data: { revokedAt: Date; graceSeal?: null };
|
|
102
104
|
}): Promise<{ count: number }>;
|
|
103
105
|
};
|
|
104
106
|
}
|
|
@@ -145,7 +147,10 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
|
|
|
145
147
|
const prisma = await getPrisma();
|
|
146
148
|
await prisma.oAuthRefreshToken.updateMany({
|
|
147
149
|
where: { tokenHash: { in: [...tokenHashes] } },
|
|
148
|
-
|
|
150
|
+
// The seals go with the revocation. A dead lineage that still carries
|
|
151
|
+
// openable seals is a chain anyone holding one of its plaintexts can
|
|
152
|
+
// still walk offline, which would make the revocation cosmetic.
|
|
153
|
+
data: { revokedAt: at, graceSeal: null },
|
|
149
154
|
});
|
|
150
155
|
},
|
|
151
156
|
async rotate(successor, parentHash, at) {
|
|
@@ -162,7 +167,12 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
|
|
|
162
167
|
// use of the parent that now never comes).
|
|
163
168
|
const { count } = await tx.oAuthRefreshToken.updateMany({
|
|
164
169
|
where: { tokenHash: parentHash, revokedAt: null },
|
|
165
|
-
|
|
170
|
+
// `graceSeal: null` is part of the claim, not a cleanup. The parent's
|
|
171
|
+
// seal is openable by the plaintext it was rotated from, so leaving it
|
|
172
|
+
// behind would chain: one historical plaintext plus a copy of this
|
|
173
|
+
// table walks forward to the live token offline, hop by hop, with no
|
|
174
|
+
// server call to detect. Cleared here, at most one hop is ever open.
|
|
175
|
+
data: { revokedAt: at, graceSeal: null },
|
|
166
176
|
});
|
|
167
177
|
// Lost the claim: write NOTHING. The zero-row update commits as the no-op
|
|
168
178
|
// it is, so there is nothing to roll back.
|
|
@@ -177,7 +187,8 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
|
|
|
177
187
|
const prisma = await getPrisma();
|
|
178
188
|
const { count } = await prisma.oAuthRefreshToken.updateMany({
|
|
179
189
|
where: { userEmail, clientId, revokedAt: null },
|
|
180
|
-
|
|
190
|
+
// Disconnecting a host must leave nothing openable behind either.
|
|
191
|
+
data: { revokedAt: new Date(), graceSeal: null },
|
|
181
192
|
});
|
|
182
193
|
return count;
|
|
183
194
|
},
|