@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.
Files changed (71) hide show
  1. package/dist/authority.d.ts +57 -0
  2. package/dist/authority.js +35 -0
  3. package/dist/first-value-journey.d.ts +156 -0
  4. package/dist/first-value-journey.js +213 -0
  5. package/dist/identity.d.ts +4 -4
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +3 -3
  8. package/dist/mcp.d.ts +199 -0
  9. package/dist/mcp.js +214 -0
  10. package/dist/oauth.js +83 -7
  11. package/dist/operations.d.ts +155 -1
  12. package/dist/operations.js +104 -2
  13. package/generated/v1/examples/context.read.response.json +6 -0
  14. package/generated/v1/examples/credentials.connection.response.json +16 -0
  15. package/generated/v1/manifest.json +53 -0
  16. package/generated/v1/openapi.json +689 -0
  17. package/generated/v1/policy-matrix.json +14 -0
  18. package/generated/v1/schemas/context.read.response.json +43 -0
  19. package/generated/v1/schemas/credentials.connection.response.json +64 -0
  20. package/generated/v1/snippets/attempts.list.json +1 -0
  21. package/generated/v1/snippets/attempts.read.json +1 -0
  22. package/generated/v1/snippets/context.read.json +6 -0
  23. package/generated/v1/snippets/credentials.connection.json +6 -0
  24. package/generated/v1/snippets/credentials.create.json +1 -0
  25. package/generated/v1/snippets/credentials.list.json +1 -0
  26. package/generated/v1/snippets/credentials.revoke.json +1 -0
  27. package/generated/v1/snippets/credentials.rotate.json +1 -0
  28. package/generated/v1/snippets/endpoints.create.json +1 -0
  29. package/generated/v1/snippets/endpoints.disable.json +1 -0
  30. package/generated/v1/snippets/endpoints.list.json +1 -0
  31. package/generated/v1/snippets/endpoints.read.json +1 -0
  32. package/generated/v1/snippets/endpoints.rotateSigningKey.json +1 -0
  33. package/generated/v1/snippets/endpoints.update.json +1 -0
  34. package/generated/v1/snippets/endpoints.verify.json +1 -0
  35. package/generated/v1/snippets/forecast.read.json +1 -0
  36. package/generated/v1/snippets/forecast.windows.json +1 -0
  37. package/generated/v1/snippets/grants.list.json +1 -0
  38. package/generated/v1/snippets/grants.revoke.json +1 -0
  39. package/generated/v1/snippets/journey/await-occurrence.json +6 -0
  40. package/generated/v1/snippets/journey/check-credential.json +6 -0
  41. package/generated/v1/snippets/journey/check-service.json +6 -0
  42. package/generated/v1/snippets/journey/clean-up.json +6 -0
  43. package/generated/v1/snippets/journey/create-destination.json +6 -0
  44. package/generated/v1/snippets/journey/create-one-shot.json +6 -0
  45. package/generated/v1/snippets/journey/read-attempt.json +6 -0
  46. package/generated/v1/snippets/journey/read-schedule.json +6 -0
  47. package/generated/v1/snippets/journey/replay-one-shot.json +6 -0
  48. package/generated/v1/snippets/meta.scaffold.json +1 -0
  49. package/generated/v1/snippets/meta.version.json +1 -0
  50. package/generated/v1/snippets/occurrences.list.json +1 -0
  51. package/generated/v1/snippets/occurrences.read.json +1 -0
  52. package/generated/v1/snippets/occurrences.redeliver.json +1 -0
  53. package/generated/v1/snippets/projects.create.json +1 -0
  54. package/generated/v1/snippets/projects.list.json +1 -0
  55. package/generated/v1/snippets/schedules.archive.json +1 -0
  56. package/generated/v1/snippets/schedules.cancel.json +1 -0
  57. package/generated/v1/snippets/schedules.create.json +1 -0
  58. package/generated/v1/snippets/schedules.list.json +1 -0
  59. package/generated/v1/snippets/schedules.pause.json +1 -0
  60. package/generated/v1/snippets/schedules.read.json +1 -0
  61. package/generated/v1/snippets/schedules.replace.json +1 -0
  62. package/generated/v1/snippets/schedules.resume.json +1 -0
  63. package/generated/v1/snippets/sinkdeliveries.list.json +1 -0
  64. package/generated/v1/snippets/sinkdeliveries.read.json +1 -0
  65. package/generated/v1/snippets/testsinks.configure.json +1 -0
  66. package/generated/v1/snippets/testsinks.create.json +1 -0
  67. package/generated/v1/snippets/testsinks.delete.json +1 -0
  68. package/generated/v1/snippets/testsinks.list.json +1 -0
  69. package/generated/v1/snippets/testsinks.read.json +1 -0
  70. package/generated/v1/snippets/usage.read.json +1 -0
  71. 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 containing a dot, such as `com.example.app:/callback`.
277
- * RFC 8252 §7.1's reverse-domain form, which is what a mobile or editor client
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. The dot is RFC 8252's own test for one, and it is
331
- // what keeps `myapp:/callback` — a scheme anybody could claim — out.
332
- if (!url.protocol.slice(0, -1).includes(".")) {
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 in reverse-domain form",
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
  });