@greatwait/contracts 0.1.0-alpha.2 → 0.1.0-alpha.3
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/dist/authority.d.ts +57 -0
- package/dist/authority.js +35 -0
- package/dist/first-value-journey.d.ts +156 -0
- package/dist/first-value-journey.js +213 -0
- package/dist/identity.d.ts +4 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +199 -0
- package/dist/mcp.js +214 -0
- package/dist/oauth.js +83 -7
- package/dist/operations.d.ts +155 -1
- package/dist/operations.js +104 -2
- package/generated/v1/examples/context.read.response.json +6 -0
- package/generated/v1/examples/credentials.connection.response.json +16 -0
- package/generated/v1/manifest.json +53 -0
- package/generated/v1/openapi.json +689 -0
- package/generated/v1/policy-matrix.json +14 -0
- package/generated/v1/schemas/context.read.response.json +43 -0
- package/generated/v1/schemas/credentials.connection.response.json +64 -0
- package/generated/v1/snippets/attempts.list.json +1 -0
- package/generated/v1/snippets/attempts.read.json +1 -0
- package/generated/v1/snippets/context.read.json +6 -0
- package/generated/v1/snippets/credentials.connection.json +6 -0
- package/generated/v1/snippets/credentials.create.json +1 -0
- package/generated/v1/snippets/credentials.list.json +1 -0
- package/generated/v1/snippets/credentials.revoke.json +1 -0
- package/generated/v1/snippets/credentials.rotate.json +1 -0
- package/generated/v1/snippets/endpoints.create.json +1 -0
- package/generated/v1/snippets/endpoints.disable.json +1 -0
- package/generated/v1/snippets/endpoints.list.json +1 -0
- package/generated/v1/snippets/endpoints.read.json +1 -0
- package/generated/v1/snippets/endpoints.rotateSigningKey.json +1 -0
- package/generated/v1/snippets/endpoints.update.json +1 -0
- package/generated/v1/snippets/endpoints.verify.json +1 -0
- package/generated/v1/snippets/forecast.read.json +1 -0
- package/generated/v1/snippets/forecast.windows.json +1 -0
- package/generated/v1/snippets/grants.list.json +1 -0
- package/generated/v1/snippets/grants.revoke.json +1 -0
- package/generated/v1/snippets/journey/await-occurrence.json +6 -0
- package/generated/v1/snippets/journey/check-credential.json +6 -0
- package/generated/v1/snippets/journey/check-service.json +6 -0
- package/generated/v1/snippets/journey/clean-up.json +6 -0
- package/generated/v1/snippets/journey/create-destination.json +6 -0
- package/generated/v1/snippets/journey/create-one-shot.json +6 -0
- package/generated/v1/snippets/journey/read-attempt.json +6 -0
- package/generated/v1/snippets/journey/read-schedule.json +6 -0
- package/generated/v1/snippets/journey/replay-one-shot.json +6 -0
- package/generated/v1/snippets/meta.scaffold.json +1 -0
- package/generated/v1/snippets/meta.version.json +1 -0
- package/generated/v1/snippets/occurrences.list.json +1 -0
- package/generated/v1/snippets/occurrences.read.json +1 -0
- package/generated/v1/snippets/occurrences.redeliver.json +1 -0
- package/generated/v1/snippets/projects.create.json +1 -0
- package/generated/v1/snippets/projects.list.json +1 -0
- package/generated/v1/snippets/schedules.archive.json +1 -0
- package/generated/v1/snippets/schedules.cancel.json +1 -0
- package/generated/v1/snippets/schedules.create.json +1 -0
- package/generated/v1/snippets/schedules.list.json +1 -0
- package/generated/v1/snippets/schedules.pause.json +1 -0
- package/generated/v1/snippets/schedules.read.json +1 -0
- package/generated/v1/snippets/schedules.replace.json +1 -0
- package/generated/v1/snippets/schedules.resume.json +1 -0
- package/generated/v1/snippets/sinkdeliveries.list.json +1 -0
- package/generated/v1/snippets/sinkdeliveries.read.json +1 -0
- package/generated/v1/snippets/testsinks.configure.json +1 -0
- package/generated/v1/snippets/testsinks.create.json +1 -0
- package/generated/v1/snippets/testsinks.delete.json +1 -0
- package/generated/v1/snippets/testsinks.list.json +1 -0
- package/generated/v1/snippets/testsinks.read.json +1 -0
- package/generated/v1/snippets/usage.read.json +1 -0
- package/package.json +2 -2
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Where the remote MCP surface lives, and what it tells an agent about itself.
|
|
4
|
+
*
|
|
5
|
+
* Stated once for the same reason `OAUTH_ROUTES` is: two readers must agree.
|
|
6
|
+
* The router serves these paths and the protected-resource metadata document
|
|
7
|
+
* advertises them, and a path that drifts from its advertisement is not a 404 —
|
|
8
|
+
* it is a client that cannot authenticate and cannot say why.
|
|
9
|
+
*
|
|
10
|
+
* Outside `/v1`, following the convention `CLERK_WEBHOOK_PATH` and
|
|
11
|
+
* `OAUTH_ROUTES` set. The `/v1` surface is the operation registry, every entry
|
|
12
|
+
* of which is projected into OpenAPI, the SDK, an MCP tool and the contract
|
|
13
|
+
* mock; the MCP endpoint is the transport those tools are served over, and a
|
|
14
|
+
* transport that appeared in its own tool list would be a tool an agent could
|
|
15
|
+
* point at itself.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MCP_ROUTES: {
|
|
18
|
+
/**
|
|
19
|
+
* The single Streamable HTTP endpoint. POST only; `GET` and `DELETE` answer
|
|
20
|
+
* `405`, because this endpoint opens no standalone event stream and mints no
|
|
21
|
+
* session to terminate.
|
|
22
|
+
*
|
|
23
|
+
* Served at this path **and** at the same path with a trailing slash, without
|
|
24
|
+
* a redirect between them. A `301`/`302` on a POST is rewritten by the fetch
|
|
25
|
+
* standard into a GET with the body dropped, so a client configured with the
|
|
26
|
+
* other spelling would have its opening request silently become the GET this
|
|
27
|
+
* endpoint refuses — and read that as "no MCP server here".
|
|
28
|
+
*/
|
|
29
|
+
readonly endpoint: "/mcp";
|
|
30
|
+
/**
|
|
31
|
+
* RFC 9728 metadata for the MCP endpoint specifically.
|
|
32
|
+
*
|
|
33
|
+
* Served *beside* the origin's own document rather than instead of it. RFC
|
|
34
|
+
* 9728 §3.1 places the metadata for a resource with a path component at
|
|
35
|
+
* `/.well-known/oauth-protected-resource` followed by that path, so a client
|
|
36
|
+
* that treats `https://host/mcp` as the resource it is authorizing for
|
|
37
|
+
* constructs exactly this URL — and one that follows the `resource_metadata`
|
|
38
|
+
* link out of the `401` is sent here too. Both arrive at a document naming
|
|
39
|
+
* the same authorization server.
|
|
40
|
+
*/
|
|
41
|
+
readonly protectedResourceMetadata: "/.well-known/oauth-protected-resource/mcp";
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* What the server tells a model about itself, once, before any tool call.
|
|
45
|
+
*
|
|
46
|
+
* MCP calls this `instructions` and hands it to the model as context. It is
|
|
47
|
+
* kept short and factual: an agent that has read the tool descriptions knows
|
|
48
|
+
* what each one does, and what it cannot work out from them is the shape of the
|
|
49
|
+
* authority it is acting under — which is the one thing that decides whether an
|
|
50
|
+
* instruction like "create this in production" is even expressible.
|
|
51
|
+
*/
|
|
52
|
+
export declare const MCP_INSTRUCTIONS: string;
|
|
53
|
+
/**
|
|
54
|
+
* What a signed-in member is told about a key they have just issued, so that a
|
|
55
|
+
* connection test can tell "the server refused you" from "the server does not
|
|
56
|
+
* know this key" from "this key sees fewer tools than you meant it to".
|
|
57
|
+
*
|
|
58
|
+
* **Why the endpoint returns the tool names rather than a count.** The
|
|
59
|
+
* connection test drives `tools/list` through the official MCP client with the
|
|
60
|
+
* transient secret, and then has to decide whether what came back is about the
|
|
61
|
+
* credential the member means. A count cannot answer that: two credentials with
|
|
62
|
+
* different scopes routinely authorize the same number of tools. The names can,
|
|
63
|
+
* and they are already public — they are the operation ids with underscores,
|
|
64
|
+
* published in the parity table and in every client's own listing.
|
|
65
|
+
*
|
|
66
|
+
* That comparison is what binds the secret in the browser to the credential the
|
|
67
|
+
* session named. The session's half is the tenant check on the way in: the
|
|
68
|
+
* route refuses a credential whose authority is not this session's project and
|
|
69
|
+
* environment, so a member cannot ask this question about another tenant's key.
|
|
70
|
+
*/
|
|
71
|
+
export declare const MCP_CONNECTION_STATES: readonly ["usable", "revoked", "expired"];
|
|
72
|
+
export type McpConnectionState = (typeof MCP_CONNECTION_STATES)[number];
|
|
73
|
+
/**
|
|
74
|
+
* The steps a connection test performs, in order, and the only steps it has.
|
|
75
|
+
*
|
|
76
|
+
* Closed because the result is reported as an activation fact, and an open list
|
|
77
|
+
* of step names is a free-text field with a schema in front of it.
|
|
78
|
+
*/
|
|
79
|
+
export declare const MCP_CONNECTION_CHECKS: readonly ["initialize", "tools", "authority"];
|
|
80
|
+
export type McpConnectionCheck = (typeof MCP_CONNECTION_CHECKS)[number];
|
|
81
|
+
/** What a step did. Binary, plus the case where an earlier step stopped it. */
|
|
82
|
+
export declare const MCP_CONNECTION_OUTCOMES: readonly ["passed", "failed", "skipped"];
|
|
83
|
+
export type McpConnectionOutcome = (typeof MCP_CONNECTION_OUTCOMES)[number];
|
|
84
|
+
/**
|
|
85
|
+
* The bounded answer a connection test starts from.
|
|
86
|
+
*
|
|
87
|
+
* Every field is either an address this deployment publishes or a fact about a
|
|
88
|
+
* credential the caller's own session may already read from the credential
|
|
89
|
+
* listing. Nothing here is new disclosure, and nothing here is the secret: the
|
|
90
|
+
* transient value never travels to this route, and the route has nothing it
|
|
91
|
+
* could echo.
|
|
92
|
+
*/
|
|
93
|
+
export declare const mcpConnectionSchema: z.ZodObject<{
|
|
94
|
+
discovery: z.ZodString;
|
|
95
|
+
displayPrefix: z.ZodString;
|
|
96
|
+
endpoint: z.ZodString;
|
|
97
|
+
id: z.ZodString;
|
|
98
|
+
scopes: z.ZodArray<z.ZodEnum<{
|
|
99
|
+
"credentials:administer": "credentials:administer";
|
|
100
|
+
"credentials:read": "credentials:read";
|
|
101
|
+
"credentials:write": "credentials:write";
|
|
102
|
+
"endpoints:read": "endpoints:read";
|
|
103
|
+
"endpoints:write": "endpoints:write";
|
|
104
|
+
"forecast:read": "forecast:read";
|
|
105
|
+
"grants:read": "grants:read";
|
|
106
|
+
"grants:write": "grants:write";
|
|
107
|
+
"occurrences:read": "occurrences:read";
|
|
108
|
+
"occurrences:redeliver": "occurrences:redeliver";
|
|
109
|
+
"projects:read": "projects:read";
|
|
110
|
+
"projects:write": "projects:write";
|
|
111
|
+
"schedules:read": "schedules:read";
|
|
112
|
+
"schedules:write": "schedules:write";
|
|
113
|
+
"usage:read": "usage:read";
|
|
114
|
+
}>>;
|
|
115
|
+
state: z.ZodEnum<{
|
|
116
|
+
revoked: "revoked";
|
|
117
|
+
usable: "usable";
|
|
118
|
+
expired: "expired";
|
|
119
|
+
}>;
|
|
120
|
+
tools: z.ZodArray<z.ZodString>;
|
|
121
|
+
}, z.core.$strip>;
|
|
122
|
+
export type McpConnection = z.infer<typeof mcpConnectionSchema>;
|
|
123
|
+
/**
|
|
124
|
+
* Why a connection test stopped, from a closed list.
|
|
125
|
+
*
|
|
126
|
+
* **Closed because the alternative leaks.** The natural implementation of a
|
|
127
|
+
* diagnostic is to show what the server said, and what the server said is a
|
|
128
|
+
* string this product did not write — a proxy's error page, a transport
|
|
129
|
+
* library's message, a response body. Any of those can carry a URL, a header, or
|
|
130
|
+
* a fragment of the credential that produced them. A reason from this list
|
|
131
|
+
* carries none of that and is enough to act on, which is the whole bargain.
|
|
132
|
+
*/
|
|
133
|
+
export declare const MCP_CONNECTION_FAILURES: readonly ["unauthorized", "unreachable", "protocol", "mismatch"];
|
|
134
|
+
export type McpConnectionFailure = (typeof MCP_CONNECTION_FAILURES)[number];
|
|
135
|
+
/**
|
|
136
|
+
* How long something took, coarsely.
|
|
137
|
+
*
|
|
138
|
+
* A duration recorded to the millisecond is a fingerprint: repeated across a
|
|
139
|
+
* small population it distinguishes one member's connection from another's. The
|
|
140
|
+
* question an activation fact answers is "was this fast enough to feel like it
|
|
141
|
+
* worked", and a bucket answers it.
|
|
142
|
+
*/
|
|
143
|
+
export declare const MCP_ACTIVATION_DURATIONS: readonly ["under_1s", "under_5s", "under_30s", "over_30s"];
|
|
144
|
+
export type McpActivationDuration = (typeof MCP_ACTIVATION_DURATIONS)[number];
|
|
145
|
+
/** The bucket a measured duration falls in. */
|
|
146
|
+
export declare function activationDuration(elapsedMs: number): McpActivationDuration;
|
|
147
|
+
/**
|
|
148
|
+
* What is recorded when somebody connects a client, and the only thing that is.
|
|
149
|
+
*
|
|
150
|
+
* **Every field is drawn from a closed set, and that is the point rather than a
|
|
151
|
+
* convenience.** There is no free-text field, no identifier, no address and no
|
|
152
|
+
* measurement finer than a bucket, so an event of this shape cannot carry an
|
|
153
|
+
* account, an organization, a project, an OAuth URL, a token, a destination or a
|
|
154
|
+
* payload however carelessly it is constructed. The project specification asks
|
|
155
|
+
* for activation evidence containing "durations and coarse outcomes, never
|
|
156
|
+
* callback bodies, secrets, tenant identifiers, or customer payloads"; a schema
|
|
157
|
+
* that admits only those is how that becomes a property instead of a promise.
|
|
158
|
+
*
|
|
159
|
+
* `client` is validated against the catalog rather than typed as a string, so a
|
|
160
|
+
* client nobody has walked cannot appear in activation data as though it had.
|
|
161
|
+
*
|
|
162
|
+
* **Strict, not merely typed, and a test is why.** An ordinary object schema
|
|
163
|
+
* *strips* a field it does not know, which would have made an emitter that added
|
|
164
|
+
* `project` look like it was recording one while the field silently vanished.
|
|
165
|
+
* Stripping is safe and dishonest: the caller believes something is being
|
|
166
|
+
* recorded that is not, and nothing ever tells them. "Closed" has to mean the
|
|
167
|
+
* schema refuses what it does not recognise, so the mistake surfaces where it
|
|
168
|
+
* was made.
|
|
169
|
+
*/
|
|
170
|
+
export declare const mcpActivationEventSchema: z.ZodObject<{
|
|
171
|
+
authKind: z.ZodEnum<{
|
|
172
|
+
oauth: "oauth";
|
|
173
|
+
"api-key": "api-key";
|
|
174
|
+
}>;
|
|
175
|
+
checks: z.ZodRecord<z.ZodEnum<{
|
|
176
|
+
authority: "authority";
|
|
177
|
+
initialize: "initialize";
|
|
178
|
+
tools: "tools";
|
|
179
|
+
}>, z.ZodEnum<{
|
|
180
|
+
failed: "failed";
|
|
181
|
+
passed: "passed";
|
|
182
|
+
skipped: "skipped";
|
|
183
|
+
}>>;
|
|
184
|
+
client: z.ZodString;
|
|
185
|
+
duration: z.ZodEnum<{
|
|
186
|
+
under_1s: "under_1s";
|
|
187
|
+
under_5s: "under_5s";
|
|
188
|
+
under_30s: "under_30s";
|
|
189
|
+
over_30s: "over_30s";
|
|
190
|
+
}>;
|
|
191
|
+
failure: z.ZodOptional<z.ZodEnum<{
|
|
192
|
+
unauthorized: "unauthorized";
|
|
193
|
+
unreachable: "unreachable";
|
|
194
|
+
protocol: "protocol";
|
|
195
|
+
mismatch: "mismatch";
|
|
196
|
+
}>>;
|
|
197
|
+
type: z.ZodLiteral<"mcp.connection_test">;
|
|
198
|
+
}, z.core.$strict>;
|
|
199
|
+
export type McpActivationEvent = z.infer<typeof mcpActivationEventSchema>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { scopeSchema } from "./authority.js";
|
|
3
|
+
import { apiCredentialIdSchema } from "./identifiers.js";
|
|
4
|
+
import { CREDENTIAL_PREFIX_PATTERN } from "./secrets.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the remote MCP surface lives, and what it tells an agent about itself.
|
|
7
|
+
*
|
|
8
|
+
* Stated once for the same reason `OAUTH_ROUTES` is: two readers must agree.
|
|
9
|
+
* The router serves these paths and the protected-resource metadata document
|
|
10
|
+
* advertises them, and a path that drifts from its advertisement is not a 404 —
|
|
11
|
+
* it is a client that cannot authenticate and cannot say why.
|
|
12
|
+
*
|
|
13
|
+
* Outside `/v1`, following the convention `CLERK_WEBHOOK_PATH` and
|
|
14
|
+
* `OAUTH_ROUTES` set. The `/v1` surface is the operation registry, every entry
|
|
15
|
+
* of which is projected into OpenAPI, the SDK, an MCP tool and the contract
|
|
16
|
+
* mock; the MCP endpoint is the transport those tools are served over, and a
|
|
17
|
+
* transport that appeared in its own tool list would be a tool an agent could
|
|
18
|
+
* point at itself.
|
|
19
|
+
*/
|
|
20
|
+
export const MCP_ROUTES = {
|
|
21
|
+
/**
|
|
22
|
+
* The single Streamable HTTP endpoint. POST only; `GET` and `DELETE` answer
|
|
23
|
+
* `405`, because this endpoint opens no standalone event stream and mints no
|
|
24
|
+
* session to terminate.
|
|
25
|
+
*
|
|
26
|
+
* Served at this path **and** at the same path with a trailing slash, without
|
|
27
|
+
* a redirect between them. A `301`/`302` on a POST is rewritten by the fetch
|
|
28
|
+
* standard into a GET with the body dropped, so a client configured with the
|
|
29
|
+
* other spelling would have its opening request silently become the GET this
|
|
30
|
+
* endpoint refuses — and read that as "no MCP server here".
|
|
31
|
+
*/
|
|
32
|
+
endpoint: "/mcp",
|
|
33
|
+
/**
|
|
34
|
+
* RFC 9728 metadata for the MCP endpoint specifically.
|
|
35
|
+
*
|
|
36
|
+
* Served *beside* the origin's own document rather than instead of it. RFC
|
|
37
|
+
* 9728 §3.1 places the metadata for a resource with a path component at
|
|
38
|
+
* `/.well-known/oauth-protected-resource` followed by that path, so a client
|
|
39
|
+
* that treats `https://host/mcp` as the resource it is authorizing for
|
|
40
|
+
* constructs exactly this URL — and one that follows the `resource_metadata`
|
|
41
|
+
* link out of the `401` is sent here too. Both arrive at a document naming
|
|
42
|
+
* the same authorization server.
|
|
43
|
+
*/
|
|
44
|
+
protectedResourceMetadata: "/.well-known/oauth-protected-resource/mcp",
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* What the server tells a model about itself, once, before any tool call.
|
|
48
|
+
*
|
|
49
|
+
* MCP calls this `instructions` and hands it to the model as context. It is
|
|
50
|
+
* kept short and factual: an agent that has read the tool descriptions knows
|
|
51
|
+
* what each one does, and what it cannot work out from them is the shape of the
|
|
52
|
+
* authority it is acting under — which is the one thing that decides whether an
|
|
53
|
+
* instruction like "create this in production" is even expressible.
|
|
54
|
+
*/
|
|
55
|
+
export const MCP_INSTRUCTIONS = [
|
|
56
|
+
"Greatwait schedules work and delivers it to an endpoint at the time asked for.",
|
|
57
|
+
"Every tool acts inside one organization, project and environment, fixed by the credential this connection was authorized with; no argument can name another, and there is no tool that moves between them.",
|
|
58
|
+
"Reads are safe to repeat. Writes are annotated, and the ones marked destructive settle or discard an occurrence that would otherwise have fired.",
|
|
59
|
+
"A refusal comes back as a problem document with a stable `code`: act on the code, not on the wording.",
|
|
60
|
+
].join(" ");
|
|
61
|
+
/**
|
|
62
|
+
* What a signed-in member is told about a key they have just issued, so that a
|
|
63
|
+
* connection test can tell "the server refused you" from "the server does not
|
|
64
|
+
* know this key" from "this key sees fewer tools than you meant it to".
|
|
65
|
+
*
|
|
66
|
+
* **Why the endpoint returns the tool names rather than a count.** The
|
|
67
|
+
* connection test drives `tools/list` through the official MCP client with the
|
|
68
|
+
* transient secret, and then has to decide whether what came back is about the
|
|
69
|
+
* credential the member means. A count cannot answer that: two credentials with
|
|
70
|
+
* different scopes routinely authorize the same number of tools. The names can,
|
|
71
|
+
* and they are already public — they are the operation ids with underscores,
|
|
72
|
+
* published in the parity table and in every client's own listing.
|
|
73
|
+
*
|
|
74
|
+
* That comparison is what binds the secret in the browser to the credential the
|
|
75
|
+
* session named. The session's half is the tenant check on the way in: the
|
|
76
|
+
* route refuses a credential whose authority is not this session's project and
|
|
77
|
+
* environment, so a member cannot ask this question about another tenant's key.
|
|
78
|
+
*/
|
|
79
|
+
export const MCP_CONNECTION_STATES = ["usable", "revoked", "expired"];
|
|
80
|
+
/**
|
|
81
|
+
* The steps a connection test performs, in order, and the only steps it has.
|
|
82
|
+
*
|
|
83
|
+
* Closed because the result is reported as an activation fact, and an open list
|
|
84
|
+
* of step names is a free-text field with a schema in front of it.
|
|
85
|
+
*/
|
|
86
|
+
export const MCP_CONNECTION_CHECKS = [
|
|
87
|
+
/** The client completed the MCP handshake against the endpoint. */
|
|
88
|
+
"initialize",
|
|
89
|
+
/** The client listed tools, and the listing was well formed. */
|
|
90
|
+
"tools",
|
|
91
|
+
/** The listing was the one this credential's scopes authorize. */
|
|
92
|
+
"authority",
|
|
93
|
+
];
|
|
94
|
+
/** What a step did. Binary, plus the case where an earlier step stopped it. */
|
|
95
|
+
export const MCP_CONNECTION_OUTCOMES = ["passed", "failed", "skipped"];
|
|
96
|
+
/**
|
|
97
|
+
* The bounded answer a connection test starts from.
|
|
98
|
+
*
|
|
99
|
+
* Every field is either an address this deployment publishes or a fact about a
|
|
100
|
+
* credential the caller's own session may already read from the credential
|
|
101
|
+
* listing. Nothing here is new disclosure, and nothing here is the secret: the
|
|
102
|
+
* transient value never travels to this route, and the route has nothing it
|
|
103
|
+
* could echo.
|
|
104
|
+
*/
|
|
105
|
+
export const mcpConnectionSchema = z.object({
|
|
106
|
+
/** The RFC 9728 document for the endpoint, absolute. */
|
|
107
|
+
discovery: z.string().url(),
|
|
108
|
+
/** The non-secret leading segment, so a client can check it holds this key. */
|
|
109
|
+
displayPrefix: z.string().regex(CREDENTIAL_PREFIX_PATTERN),
|
|
110
|
+
/** The Streamable HTTP endpoint, absolute, ready to paste. */
|
|
111
|
+
endpoint: z.string().url(),
|
|
112
|
+
/**
|
|
113
|
+
* Which credential this describes.
|
|
114
|
+
*
|
|
115
|
+
* A document addressed by an identifier echoes it — the rule
|
|
116
|
+
* `published-examples.worker.test.ts` enforces on every read, and the reason
|
|
117
|
+
* is a caller holding two of these needing to tell them apart. The display
|
|
118
|
+
* prefix is a recognition aid rather than an identity, and it is not the value
|
|
119
|
+
* a later call is addressed by.
|
|
120
|
+
*/
|
|
121
|
+
id: apiCredentialIdSchema,
|
|
122
|
+
/** The scopes fixed into this credential when it was issued. */
|
|
123
|
+
scopes: z.array(scopeSchema),
|
|
124
|
+
state: z.enum(MCP_CONNECTION_STATES),
|
|
125
|
+
/**
|
|
126
|
+
* The MCP tool names these scopes authorize, sorted.
|
|
127
|
+
*
|
|
128
|
+
* Computed by the same authorization policy `/mcp` runs when it decides what
|
|
129
|
+
* to put in `tools/list`, rather than by a second rule that would drift from
|
|
130
|
+
* it. A connection test compares this against what the official client
|
|
131
|
+
* actually listed; equal means the secret in the browser is the credential the
|
|
132
|
+
* session named, and unequal means it is not — which no count could tell them.
|
|
133
|
+
*/
|
|
134
|
+
tools: z.array(z.string()),
|
|
135
|
+
});
|
|
136
|
+
/**
|
|
137
|
+
* Why a connection test stopped, from a closed list.
|
|
138
|
+
*
|
|
139
|
+
* **Closed because the alternative leaks.** The natural implementation of a
|
|
140
|
+
* diagnostic is to show what the server said, and what the server said is a
|
|
141
|
+
* string this product did not write — a proxy's error page, a transport
|
|
142
|
+
* library's message, a response body. Any of those can carry a URL, a header, or
|
|
143
|
+
* a fragment of the credential that produced them. A reason from this list
|
|
144
|
+
* carries none of that and is enough to act on, which is the whole bargain.
|
|
145
|
+
*/
|
|
146
|
+
export const MCP_CONNECTION_FAILURES = [
|
|
147
|
+
/** The endpoint answered, and refused the credential. */
|
|
148
|
+
"unauthorized",
|
|
149
|
+
/** Nothing answered, or what answered was not this endpoint. */
|
|
150
|
+
"unreachable",
|
|
151
|
+
/** It answered, but not in a way an MCP client could complete. */
|
|
152
|
+
"protocol",
|
|
153
|
+
/** It listed tools, and they are not the ones this credential authorizes. */
|
|
154
|
+
"mismatch",
|
|
155
|
+
];
|
|
156
|
+
/**
|
|
157
|
+
* How long something took, coarsely.
|
|
158
|
+
*
|
|
159
|
+
* A duration recorded to the millisecond is a fingerprint: repeated across a
|
|
160
|
+
* small population it distinguishes one member's connection from another's. The
|
|
161
|
+
* question an activation fact answers is "was this fast enough to feel like it
|
|
162
|
+
* worked", and a bucket answers it.
|
|
163
|
+
*/
|
|
164
|
+
export const MCP_ACTIVATION_DURATIONS = [
|
|
165
|
+
"under_1s",
|
|
166
|
+
"under_5s",
|
|
167
|
+
"under_30s",
|
|
168
|
+
"over_30s",
|
|
169
|
+
];
|
|
170
|
+
/** The bucket a measured duration falls in. */
|
|
171
|
+
export function activationDuration(elapsedMs) {
|
|
172
|
+
if (elapsedMs < 1_000)
|
|
173
|
+
return "under_1s";
|
|
174
|
+
if (elapsedMs < 5_000)
|
|
175
|
+
return "under_5s";
|
|
176
|
+
if (elapsedMs < 30_000)
|
|
177
|
+
return "under_30s";
|
|
178
|
+
return "over_30s";
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* What is recorded when somebody connects a client, and the only thing that is.
|
|
182
|
+
*
|
|
183
|
+
* **Every field is drawn from a closed set, and that is the point rather than a
|
|
184
|
+
* convenience.** There is no free-text field, no identifier, no address and no
|
|
185
|
+
* measurement finer than a bucket, so an event of this shape cannot carry an
|
|
186
|
+
* account, an organization, a project, an OAuth URL, a token, a destination or a
|
|
187
|
+
* payload however carelessly it is constructed. The project specification asks
|
|
188
|
+
* for activation evidence containing "durations and coarse outcomes, never
|
|
189
|
+
* callback bodies, secrets, tenant identifiers, or customer payloads"; a schema
|
|
190
|
+
* that admits only those is how that becomes a property instead of a promise.
|
|
191
|
+
*
|
|
192
|
+
* `client` is validated against the catalog rather than typed as a string, so a
|
|
193
|
+
* client nobody has walked cannot appear in activation data as though it had.
|
|
194
|
+
*
|
|
195
|
+
* **Strict, not merely typed, and a test is why.** An ordinary object schema
|
|
196
|
+
* *strips* a field it does not know, which would have made an emitter that added
|
|
197
|
+
* `project` look like it was recording one while the field silently vanished.
|
|
198
|
+
* Stripping is safe and dishonest: the caller believes something is being
|
|
199
|
+
* recorded that is not, and nothing ever tells them. "Closed" has to mean the
|
|
200
|
+
* schema refuses what it does not recognise, so the mistake surfaces where it
|
|
201
|
+
* was made.
|
|
202
|
+
*/
|
|
203
|
+
export const mcpActivationEventSchema = z.strictObject({
|
|
204
|
+
/** Which of the two named authentication modes was used. */
|
|
205
|
+
authKind: z.enum(["oauth", "api-key"]),
|
|
206
|
+
/** Every step, and what it did. */
|
|
207
|
+
checks: z.record(z.enum(MCP_CONNECTION_CHECKS), z.enum(MCP_CONNECTION_OUTCOMES)),
|
|
208
|
+
/** The catalog id of the client, checked against the catalog by the emitter. */
|
|
209
|
+
client: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/u),
|
|
210
|
+
duration: z.enum(MCP_ACTIVATION_DURATIONS),
|
|
211
|
+
/** Present only when something stopped, and drawn from a closed list. */
|
|
212
|
+
failure: z.enum(MCP_CONNECTION_FAILURES).optional(),
|
|
213
|
+
type: z.literal("mcp.connection_test"),
|
|
214
|
+
});
|
package/dist/oauth.js
CHANGED
|
@@ -273,9 +273,32 @@ export const OAUTH_SECRET_PATTERN = /^o(?:at|rt|ac)_(?:live|test)_[0-9a-z]{48}$/
|
|
|
273
273
|
* - `http://` on a loopback host only — `127.0.0.1`, `[::1]`, `localhost`. A
|
|
274
274
|
* native client receives its code on a listener it started itself, and there
|
|
275
275
|
* is no TLS to have. Refusing these would refuse every desktop MCP client.
|
|
276
|
-
* - a private-use scheme
|
|
277
|
-
*
|
|
278
|
-
* registers.
|
|
276
|
+
* - a private-use scheme whose URI is hierarchical, such as
|
|
277
|
+
* `com.example.app:/callback` or `cursor://anysphere.cursor-mcp/oauth/callback`.
|
|
278
|
+
* This is what a desktop or editor client registers.
|
|
279
|
+
*
|
|
280
|
+
* **The reverse-domain requirement was removed at W9-02, and the reason is that
|
|
281
|
+
* it refused a real client and bought nothing.** It required a dot in the
|
|
282
|
+
* scheme, on RFC 8252 §7.1's advice that an app choose a scheme "based on a
|
|
283
|
+
* domain name under their control, expressed in reverse order". That is
|
|
284
|
+
* guidance to *app authors*; nothing asks an authorization server to police it,
|
|
285
|
+
* and it stops no attacker — a scheme is claimed first-come by whatever
|
|
286
|
+
* installs, and an attacker can claim `com.cursor.app` as easily as `cursor`.
|
|
287
|
+
* What actually defends a public client against code interception is mandatory
|
|
288
|
+
* S256 PKCE, exact redirect matching, and a single-use code that ends its grant
|
|
289
|
+
* on replay, and this server requires all three. Meanwhile the rule refused
|
|
290
|
+
* `cursor://anysphere.cursor-mcp/oauth/callback` outright, so Cursor could not
|
|
291
|
+
* complete dynamic registration at all — observed against a hosted stage, while
|
|
292
|
+
* Linear's registration endpoint accepts that exact value.
|
|
293
|
+
*
|
|
294
|
+
* **What replaces it is structural, and it has to be.** Simply deleting the dot
|
|
295
|
+
* check would accept `javascript:alert(1)` as a redirect URI, and the consent
|
|
296
|
+
* screen navigates to the value this schema approved — which is script
|
|
297
|
+
* execution in the dashboard's own origin. So a private-use scheme is accepted
|
|
298
|
+
* only when its URI is *hierarchical*: `pathname` begins with `/`, which is the
|
|
299
|
+
* shape RFC 8252 §7.1's own examples have and which `javascript:`, `data:` and
|
|
300
|
+
* `mailto:` do not. A short deny list sits in front of that for the schemes
|
|
301
|
+
* that are hierarchical and still must never be a redirect target.
|
|
279
302
|
*
|
|
280
303
|
* A fragment is refused outright: RFC 6749 §3.1.2 forbids one, and the fragment
|
|
281
304
|
* is where the server appends nothing and the browser strips everything, so a
|
|
@@ -287,6 +310,48 @@ export const OAUTH_SECRET_PATTERN = /^o(?:at|rt|ac)_(?:live|test)_[0-9a-z]{48}$/
|
|
|
287
310
|
* exact judgement.
|
|
288
311
|
*/
|
|
289
312
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "::1", "localhost"]);
|
|
313
|
+
/**
|
|
314
|
+
* Schemes that are never a redirect target, whatever their shape.
|
|
315
|
+
*
|
|
316
|
+
* The hierarchical test below excludes the opaque ones on its own; these are
|
|
317
|
+
* the schemes that pass it and must still be refused. `file:` is the reason the
|
|
318
|
+
* list is not empty — `file:///etc/passwd` is perfectly hierarchical — and the
|
|
319
|
+
* rest are here because a deny list somebody has to remember to extend is worth
|
|
320
|
+
* less than one that already covers the obvious neighbours.
|
|
321
|
+
*/
|
|
322
|
+
const DENIED_SCHEMES = new Set([
|
|
323
|
+
// Opaque or script-bearing. The hierarchical test below catches most
|
|
324
|
+
// spellings of these on its own; `javascript://x/%0aalert(1)` is the one it
|
|
325
|
+
// cannot, which is why this list is not empty.
|
|
326
|
+
"about",
|
|
327
|
+
"blob",
|
|
328
|
+
"data",
|
|
329
|
+
"filesystem",
|
|
330
|
+
"javascript",
|
|
331
|
+
"vbscript",
|
|
332
|
+
// Hierarchical, and never a redirect target. `file:///etc/passwd` parses
|
|
333
|
+
// perfectly.
|
|
334
|
+
"file",
|
|
335
|
+
// **Registered schemes, which are by definition not private-use.** A review
|
|
336
|
+
// pointed out that the hierarchical test alone accepts `ws://`,
|
|
337
|
+
// `chrome-extension://` and friends: any scheme with an authority and a
|
|
338
|
+
// leading-slash path. No exploit follows — a redirect must still exactly
|
|
339
|
+
// match one the client registered for itself, so an attacker controls their
|
|
340
|
+
// own target through plain `https` anyway — but a redirect URI naming a
|
|
341
|
+
// scheme somebody else owns is not what "private-use" means, and refusing the
|
|
342
|
+
// well-known ones costs nothing.
|
|
343
|
+
"chrome",
|
|
344
|
+
"chrome-extension",
|
|
345
|
+
"edge",
|
|
346
|
+
"ftp",
|
|
347
|
+
"ftps",
|
|
348
|
+
"intent",
|
|
349
|
+
"moz-extension",
|
|
350
|
+
"resource",
|
|
351
|
+
"view-source",
|
|
352
|
+
"ws",
|
|
353
|
+
"wss",
|
|
354
|
+
]);
|
|
290
355
|
export const redirectUriSchema = z
|
|
291
356
|
.string()
|
|
292
357
|
.min(1)
|
|
@@ -327,12 +392,23 @@ export const redirectUriSchema = z
|
|
|
327
392
|
}
|
|
328
393
|
return;
|
|
329
394
|
}
|
|
330
|
-
// A private-use scheme.
|
|
331
|
-
|
|
332
|
-
if (
|
|
395
|
+
// A private-use scheme.
|
|
396
|
+
const scheme = url.protocol.slice(0, -1);
|
|
397
|
+
if (DENIED_SCHEMES.has(scheme)) {
|
|
398
|
+
context.addIssue({
|
|
399
|
+
code: "custom",
|
|
400
|
+
message: `${scheme} is never a redirect target`,
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// Hierarchical, which is what tells `com.example.app:/callback` and
|
|
405
|
+
// `cursor://anysphere.cursor-mcp/oauth/callback` from `javascript:alert(1)`.
|
|
406
|
+
// The consent screen navigates to whatever this approves, so an opaque URI
|
|
407
|
+
// reaching here is script execution in the dashboard's own origin.
|
|
408
|
+
if (!url.pathname.startsWith("/")) {
|
|
333
409
|
context.addIssue({
|
|
334
410
|
code: "custom",
|
|
335
|
-
message: "must be https, http on a loopback host, or a private-use scheme
|
|
411
|
+
message: "must be https, http on a loopback host, or a hierarchical private-use scheme such as com.example.app:/callback",
|
|
336
412
|
});
|
|
337
413
|
}
|
|
338
414
|
});
|