@nylorun/runtime 0.20.0-beta → 0.21.0-beta

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 (95) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +15 -10
  3. package/dist/api/components.d.ts +155 -36
  4. package/dist/api/components.js +3 -3
  5. package/dist/api/http/app.js +2 -0
  6. package/dist/api/http/routes/mcp.d.ts +9 -0
  7. package/dist/api/http/routes/mcp.js +32 -0
  8. package/dist/api/http/routes/vaults.d.ts +1 -9
  9. package/dist/api/http/routes/vaults.js +3 -103
  10. package/dist/api/openapi.js +6 -3
  11. package/dist/artifacts/media-types.d.ts +2 -0
  12. package/dist/artifacts/media-types.js +5 -0
  13. package/dist/gates/http-tool.d.ts +7 -0
  14. package/dist/gates/http-tool.js +34 -3
  15. package/dist/gates/mcp-handler.d.ts +4 -1
  16. package/dist/gates/mcp-handler.js +64 -8
  17. package/dist/gates/tenant-vaults.d.ts +9 -15
  18. package/dist/gates/tenant-vaults.js +9 -17
  19. package/dist/gates/tool-client.js +36 -17
  20. package/dist/gates/tool-contract.d.ts +19 -3
  21. package/dist/gates/tool-contract.js +9 -3
  22. package/dist/gates/tool-gate.d.ts +2 -2
  23. package/dist/harness/calls.d.ts +2 -0
  24. package/dist/harness/calls.js +13 -3
  25. package/dist/harness-api/record.d.ts +7 -6
  26. package/dist/harness-api/record.js +99 -17
  27. package/dist/harness-api/server.js +35 -27
  28. package/dist/harness-api/start.js +2 -1
  29. package/dist/host/config-for.d.ts +0 -2
  30. package/dist/host/config-for.js +0 -1
  31. package/dist/host/gates.js +0 -1
  32. package/dist/host/main.js +13 -3
  33. package/dist/host/stack-config.d.ts +5 -8
  34. package/dist/host/stack-config.js +8 -19
  35. package/dist/index.d.ts +0 -1
  36. package/dist/keys/client.js +1 -9
  37. package/dist/keys/keys.d.ts +11 -13
  38. package/dist/keys/keys.js +5 -7
  39. package/dist/management-openapi.json +315 -345
  40. package/dist/mcp/connect.d.ts +159 -13
  41. package/dist/mcp/connect.js +538 -55
  42. package/dist/mcp/pool.d.ts +11 -2
  43. package/dist/mcp/pool.js +74 -27
  44. package/dist/mcp/preview.d.ts +35 -0
  45. package/dist/mcp/preview.js +215 -0
  46. package/dist/mcp/snapshot.d.ts +79 -5
  47. package/dist/mcp/snapshot.js +145 -27
  48. package/dist/model/catalog.d.ts +14 -0
  49. package/dist/model/catalog.js +14 -1
  50. package/dist/model/pi-model.js +36 -2
  51. package/dist/openapi.json +362 -123
  52. package/dist/redact.d.ts +19 -1
  53. package/dist/redact.js +42 -13
  54. package/dist/sandbox/session-sandbox.d.ts +7 -0
  55. package/dist/sandbox/session-sandbox.js +89 -28
  56. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  57. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  58. package/dist/store/postgres/schema.d.ts +2 -261
  59. package/dist/store/postgres/schema.js +2 -36
  60. package/dist/store/postgres/store.js +1 -18
  61. package/dist/store/postgres/tenant.d.ts +14 -0
  62. package/dist/store/postgres/tenant.js +22 -2
  63. package/dist/store/types.d.ts +2 -8
  64. package/dist/tenant/artifact-tool.d.ts +9 -1
  65. package/dist/tenant/artifact-tool.js +113 -9
  66. package/dist/tenant/context.d.ts +5 -6
  67. package/dist/tenant/effects.d.ts +2 -2
  68. package/dist/tenant/effects.js +20 -5
  69. package/dist/tenant/ephemeral.d.ts +0 -6
  70. package/dist/tenant/ephemeral.js +0 -1
  71. package/dist/tenant/outbound.d.ts +14 -5
  72. package/dist/tenant/outbound.js +21 -9
  73. package/dist/tenant/runtime.js +9 -14
  74. package/dist/tenant/sessions.js +20 -3
  75. package/dist/tenant/store-pg.js +9 -0
  76. package/dist/tenant/tool-results.d.ts +57 -0
  77. package/dist/tenant/tool-results.js +298 -0
  78. package/dist/tenant/tool-search.d.ts +43 -0
  79. package/dist/tenant/tool-search.js +100 -0
  80. package/dist/tenant/transcript.d.ts +108 -0
  81. package/dist/tenant/transcript.js +13 -0
  82. package/dist/tenant/types.d.ts +0 -14
  83. package/dist/vault/crypto.d.ts +0 -7
  84. package/dist/vault/crypto.js +0 -16
  85. package/dist/vault/headers.d.ts +17 -0
  86. package/dist/vault/headers.js +61 -0
  87. package/dist/vault/service.d.ts +11 -68
  88. package/dist/vault/service.js +83 -438
  89. package/dist/vault/sources.d.ts +20 -48
  90. package/dist/vault/sources.js +25 -189
  91. package/dist/version.d.ts +1 -1
  92. package/dist/version.js +1 -1
  93. package/package.json +3 -3
  94. package/dist/vault/oauth.d.ts +0 -55
  95. package/dist/vault/oauth.js +0 -186
@@ -1,60 +1,32 @@
1
- import type { CredentialSelection } from "@nylorun/core/contracts";
1
+ /**
2
+ * Where a session's MCP or HTTP tool credential comes from: the vaults attached to the session,
3
+ * and nothing else. OSS holds no OAuth client and asks no credential resolver (protocol 10,
4
+ * D49): a person's own keys live in their user vault, the installation's in its vaults.
5
+ *
6
+ * Both authorize sites call this: the gateway (`gates/tenant-vaults.ts`, remote MCP servers and
7
+ * HTTP tools in the gates service) and the Tenant (`tenant/effects.ts`, the in-process MCP pool
8
+ * and Tool Gate). The session comes from its row, never from the harness.
9
+ */
10
+ import { type CredentialSelection } from "@nylorun/core/contracts";
2
11
  import type { AuthorizeResult, VaultService } from "./service.js";
3
- /** The operator's credential resolver (`NYLORUN_RESOLVER_URL`, `NYLORUN_RESOLVER_TOKEN`). */
4
- export interface ResolverConfig {
5
- readonly url: string;
6
- readonly token: string;
7
- }
8
- /** What the resolver is asked about: the session's row, as stored. */
12
+ /** The session a request is made for: its row, as stored. */
9
13
  export interface CredentialSession {
10
14
  readonly id: string;
11
15
  readonly ownerUserId: string;
12
- readonly activeTurnId: string | null;
13
16
  readonly vaultIds?: readonly string[];
14
17
  readonly credentialSelections?: readonly CredentialSelection[];
15
18
  }
16
- /**
17
- * One MCP request: the declared server's URL, its name and the agent that declares it. Or, with
18
- * `kind: "http"`, an HTTP tool's request: its URL and its `credential` name as `serverName`.
19
- */
19
+ /** One MCP or HTTP tool request: its URL, and the name a credential selection matches. */
20
20
  export interface McpCredentialRequest {
21
- /** Default `mcp`. */
22
- readonly kind?: "mcp" | "http";
23
21
  readonly url: string;
24
22
  /** The server's name, or the HTTP tool's `credential`: what a credential selection names. */
25
23
  readonly serverName?: string;
26
- /** The agent used as a tool that declares the server; absent for the session's root agent. */
27
- readonly agentId?: string;
28
- }
29
- export interface CredentialSourcesOptions {
30
- readonly vault: Pick<VaultService, "authorize">;
31
- /** Absent: the vaults are the only source, as before F9. */
32
- readonly resolver?: ResolverConfig;
33
- /** Default `globalThis.fetch`. */
34
- readonly fetch?: typeof fetch;
35
- /** How long the resolver may take. Default 5 s. */
36
- readonly timeoutMs?: number;
37
- readonly now?: () => number;
38
- }
39
- export declare const RESOLVER_TIMEOUT_MS = 5000;
40
- /** The longest an answer is kept, whatever its `expiresAt`. */
41
- export declare const RESOLVER_MAX_CACHE_MS: number;
42
- /** How long an answer without `expiresAt` (and a 404) is kept. */
43
- export declare const RESOLVER_DEFAULT_CACHE_MS = 60000;
44
- /** The refusal reason when the resolver could not answer. */
45
- export declare const CREDENTIAL_UNAVAILABLE = "credential_unavailable";
46
- export declare class CredentialSources {
47
- private readonly vault;
48
- private readonly resolver;
49
- private readonly fetchImpl;
50
- private readonly timeoutMs;
51
- private readonly now;
52
- private readonly cache;
53
- private readonly pending;
54
- constructor(options: CredentialSourcesOptions);
55
- /** The credential of one MCP request made for `session`. */
56
- authorize(session: CredentialSession, request: McpCredentialRequest): Promise<AuthorizeResult>;
57
- private answer;
58
- private until;
59
- private ask;
60
24
  }
25
+ /**
26
+ * The credential of one request made for `session`: the vault's answer over the session's
27
+ * attached vaults (`VaultService.authorize`). A credential with an identity header (R2b C2)
28
+ * gets the session owner's subject in it (Q2), from the session row: the run token names no
29
+ * owner, so neither the model nor the manifest can change it. A session whose owner is no
30
+ * person (the reserved `installation`) sends none (Q3).
31
+ */
32
+ export declare function sessionCredentials(vault: Pick<VaultService, "authorize">, session: CredentialSession, request: McpCredentialRequest): Promise<AuthorizeResult>;
@@ -1,196 +1,32 @@
1
1
  /**
2
- * Where a session's MCP or HTTP tool credential comes from (F9 C1): the session's attached
3
- * vaults first, then the operator's credential resolver, an HTTP service on the operator's own
4
- * network that holds people's own credentials (OSS never stores them, F9-D7).
2
+ * Where a session's MCP or HTTP tool credential comes from: the vaults attached to the session,
3
+ * and nothing else. OSS holds no OAuth client and asks no credential resolver (protocol 10,
4
+ * D49): a person's own keys live in their user vault, the installation's in its vaults.
5
5
  *
6
6
  * Both authorize sites call this: the gateway (`gates/tenant-vaults.ts`, remote MCP servers and
7
7
  * HTTP tools in the gates service) and the Tenant (`tenant/effects.ts`, the in-process MCP pool
8
- * and Tool Gate).
9
- *
10
- * ## The resolver contract
11
- *
12
- * Asked only when the attached vaults hold no credential for the URL (the vault's
13
- * `unauthenticated`), and only for a person's session (never a reserved owner):
14
- *
15
- * ```text
16
- * POST <resolver url>
17
- * Authorization: Bearer <resolver token>
18
- * { "owner": "u:priya", "session": "s_…", "turn": "t_…" | null,
19
- * "target": { "kind": "mcp", "server": "github", "agent": "support", "url": "https://…/mcp" } }
20
- *
21
- * 200 { "headers": { "authorization": "Bearer …" }, "expiresAt"?: "<ISO time>" } → authorized
22
- * 404 → unauthenticated (the call goes without one)
23
- * anything else, a bad body, or no answer in 5 s → refused: credential_unavailable
24
- * ```
25
- *
26
- * An HTTP tool's request (R2 M3) names its `credential` instead of a server:
27
- * `"target": { "kind": "http", "credential": "billing", "agent"?: "support", "url": "https://…" }`.
28
- *
29
- * `owner` and `turn` come from the session row, never from the harness. Because authorize runs
30
- * on every MCP HTTP request, answers (200 and 404) are cached per (owner, url): until
31
- * `expiresAt`, at most 5 minutes, and 60 s without one. Concurrent misses for one key share one
32
- * request. Failures are not cached. The resolver's URL is the operator's, so private addresses
33
- * are allowed; redirects are refused.
8
+ * and Tool Gate). The session comes from its row, never from the harness.
34
9
  */
35
10
  import { isSubject } from "@nylorun/core/contracts";
36
- export const RESOLVER_TIMEOUT_MS = 5_000;
37
- /** The longest an answer is kept, whatever its `expiresAt`. */
38
- export const RESOLVER_MAX_CACHE_MS = 5 * 60_000;
39
- /** How long an answer without `expiresAt` (and a 404) is kept. */
40
- export const RESOLVER_DEFAULT_CACHE_MS = 60_000;
41
- /** The refusal reason when the resolver could not answer. */
42
- export const CREDENTIAL_UNAVAILABLE = "credential_unavailable";
43
- /** RFC 9110 field names. */
44
- const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
45
- /** Headers the MCP transport owns; a resolver may not set them. */
46
- const RESERVED_HEADERS = new Set([
47
- "host",
48
- "content-length",
49
- "content-type",
50
- "transfer-encoding",
51
- "connection",
52
- "accept",
53
- "mcp-session-id",
54
- "mcp-protocol-version",
55
- ]);
56
- export class CredentialSources {
57
- vault;
58
- resolver;
59
- fetchImpl;
60
- timeoutMs;
61
- now;
62
- cache = new Map();
63
- pending = new Map();
64
- constructor(options) {
65
- this.vault = options.vault;
66
- this.resolver = options.resolver;
67
- this.fetchImpl = options.fetch ?? globalThis.fetch;
68
- this.timeoutMs = options.timeoutMs ?? RESOLVER_TIMEOUT_MS;
69
- this.now = options.now ?? Date.now;
70
- }
71
- /** The credential of one MCP request made for `session`. */
72
- async authorize(session, request) {
73
- const result = await this.vault.authorize({
74
- sessionId: session.id,
75
- vaultIds: session.vaultIds ?? [],
76
- credentialSelections: session.credentialSelections ?? [],
77
- url: request.url,
78
- ...(request.serverName === undefined ? {} : { serverName: request.serverName }),
79
- });
80
- if (result.status !== "unauthenticated" || !this.resolver)
81
- return result;
82
- // `host` and `installation` are no one: there is no person to resolve for.
83
- if (!isSubject(session.ownerUserId))
84
- return result;
85
- const answer = await this.answer(session, request, result.url);
86
- if (answer.kind === "failed")
87
- return { status: "refused", url: result.url, credentialIds: [], reason: CREDENTIAL_UNAVAILABLE };
88
- if (answer.kind === "none")
89
- return result;
90
- return { status: "authorized", url: result.url, headers: { ...answer.headers } };
91
- }
92
- async answer(session, request, url) {
93
- const key = JSON.stringify([session.ownerUserId, url]);
94
- const cached = this.cache.get(key);
95
- if (cached) {
96
- if (cached.until > this.now())
97
- return cached.answer;
98
- this.cache.delete(key);
99
- }
100
- let pending = this.pending.get(key);
101
- if (!pending) {
102
- pending = this.ask(session, request, url)
103
- .then((answer) => {
104
- if (answer.kind !== "failed") {
105
- const until = this.until(answer);
106
- if (until > this.now())
107
- this.cache.set(key, { answer, until });
108
- }
109
- return answer;
110
- })
111
- .finally(() => this.pending.delete(key));
112
- this.pending.set(key, pending);
113
- }
114
- return pending;
115
- }
116
- until(answer) {
117
- const now = this.now();
118
- const cap = now + RESOLVER_MAX_CACHE_MS;
119
- if (answer.kind === "headers" && answer.expiresAt !== undefined)
120
- return Math.min(answer.expiresAt, cap);
121
- return now + RESOLVER_DEFAULT_CACHE_MS;
122
- }
123
- async ask(session, request, url) {
124
- const resolver = this.resolver;
125
- const body = {
126
- owner: session.ownerUserId,
127
- session: session.id,
128
- turn: session.activeTurnId ?? null,
129
- target: request.kind === "http"
130
- ? {
131
- kind: "http",
132
- ...(request.serverName ? { credential: request.serverName } : {}),
133
- ...(request.agentId ? { agent: request.agentId } : {}),
134
- url,
135
- }
136
- : {
137
- kind: "mcp",
138
- ...(request.serverName ? { server: request.serverName } : {}),
139
- ...(request.agentId ? { agent: request.agentId } : {}),
140
- url,
141
- },
142
- };
143
- try {
144
- const response = await this.fetchImpl(resolver.url, {
145
- method: "POST",
146
- headers: {
147
- authorization: `Bearer ${resolver.token}`,
148
- "content-type": "application/json",
149
- accept: "application/json",
150
- },
151
- body: JSON.stringify(body),
152
- redirect: "error",
153
- signal: AbortSignal.timeout(this.timeoutMs),
154
- });
155
- if (response.status === 404) {
156
- await response.body?.cancel().catch(() => undefined);
157
- return { kind: "none" };
158
- }
159
- if (response.status !== 200) {
160
- await response.body?.cancel().catch(() => undefined);
161
- return { kind: "failed" };
162
- }
163
- return parseAnswer(await response.json(), this.now());
164
- }
165
- catch {
166
- return { kind: "failed" };
167
- }
168
- }
169
- }
170
- function parseAnswer(value, now) {
171
- if (!value || typeof value !== "object" || Array.isArray(value))
172
- return { kind: "failed" };
173
- const { headers, expiresAt } = value;
174
- if (!headers || typeof headers !== "object" || Array.isArray(headers))
175
- return { kind: "failed" };
176
- const entries = Object.entries(headers);
177
- if (entries.length === 0)
178
- return { kind: "failed" };
179
- const out = {};
180
- for (const [name, item] of entries) {
181
- if (!HEADER_NAME.test(name) || RESERVED_HEADERS.has(name.toLowerCase()))
182
- return { kind: "failed" };
183
- if (typeof item !== "string" || item === "" || /[\r\n\0]/.test(item))
184
- return { kind: "failed" };
185
- out[name.toLowerCase()] = item;
186
- }
187
- if (expiresAt === undefined || expiresAt === null)
188
- return { kind: "headers", headers: out };
189
- if (typeof expiresAt !== "string")
190
- return { kind: "failed" };
191
- const at = Date.parse(expiresAt);
192
- if (Number.isNaN(at))
193
- return { kind: "failed" };
194
- // An answer that has already expired is used once and not kept.
195
- return { kind: "headers", headers: out, expiresAt: Math.max(at, now) };
11
+ /**
12
+ * The credential of one request made for `session`: the vault's answer over the session's
13
+ * attached vaults (`VaultService.authorize`). A credential with an identity header (R2b C2)
14
+ * gets the session owner's subject in it (Q2), from the session row: the run token names no
15
+ * owner, so neither the model nor the manifest can change it. A session whose owner is no
16
+ * person (the reserved `installation`) sends none (Q3).
17
+ */
18
+ export async function sessionCredentials(vault, session, request) {
19
+ const result = await vault.authorize({
20
+ sessionId: session.id,
21
+ vaultIds: session.vaultIds ?? [],
22
+ credentialSelections: session.credentialSelections ?? [],
23
+ url: request.url,
24
+ ...(request.serverName === undefined ? {} : { serverName: request.serverName }),
25
+ });
26
+ if (result.status !== "authorized" || !result.identity || !isSubject(session.ownerUserId))
27
+ return result;
28
+ return {
29
+ ...result,
30
+ headers: { ...result.headers, [result.identity.header]: session.ownerUserId },
31
+ };
196
32
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.20.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.21.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.20.0-beta";
3
+ export const RUNTIME_VERSION = "0.21.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.20.0-beta",
3
+ "version": "0.21.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,8 +65,8 @@
65
65
  "@hono/node-server": "^2.1.3",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
67
  "@modelcontextprotocol/sdk": "^1.31.0",
68
- "@nylorun/core": "0.16.0-beta",
69
- "@nylorun/harness": "0.23.1-beta",
68
+ "@nylorun/core": "0.17.0-beta",
69
+ "@nylorun/harness": "0.24.0-beta",
70
70
  "@restatedev/restate-sdk": "1.17.2",
71
71
  "@s2-dev/streamstore": "0.28.0",
72
72
  "aws4fetch": "1.0.20",
@@ -1,55 +0,0 @@
1
- import type { AuthorizationServerMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
2
- import { HttpError } from "../tenant/http.js";
3
- export type TokenEndpointAuth = "none" | "client_secret_basic" | "client_secret_post";
4
- /** Where and how to sign in to an MCP server. */
5
- export interface OAuthServer {
6
- readonly authorizationServerUrl: string;
7
- readonly metadata: AuthorizationServerMetadata;
8
- /** The protected resource's own `resource` (RFC 9728), sent as the RFC 8707 indicator. */
9
- readonly resource?: string;
10
- /** The scopes the protected resource names, space separated. */
11
- readonly scope?: string;
12
- }
13
- /** The client this installation signs in as. */
14
- export interface OAuthClient {
15
- readonly clientId: string;
16
- readonly clientSecret?: string;
17
- readonly tokenEndpointAuth: TokenEndpointAuth;
18
- }
19
- /** A failed step, as the Tenant API answers it: a refused address is the Host's rule (400). */
20
- export declare function oauthFailure(step: string, error: unknown): HttpError;
21
- /**
22
- * RFC 9728 protected resource metadata, then RFC 8414 (or OpenID) metadata of its first
23
- * authorization server. A server without the former is its own authorization server (the MCP
24
- * spec's fallback); a refused address is never a fallback.
25
- */
26
- export declare function discoverOAuthServer(url: string, fetchFn: typeof fetch): Promise<OAuthServer>;
27
- /**
28
- * The client to sign in as: the operator's `clientId` (a public client) when given, else one
29
- * registered now (RFC 7591) when the server offers registration, else `oauth_client_required`.
30
- */
31
- export declare function oauthClient(server: OAuthServer, options: {
32
- clientId?: string;
33
- redirectUri: string;
34
- fetchFn: typeof fetch;
35
- }): Promise<OAuthClient>;
36
- /** The authorization URL, with a fresh S256 PKCE verifier. */
37
- export declare function authorizationUrl(server: OAuthServer, client: OAuthClient, options: {
38
- redirectUri: string;
39
- state: string;
40
- }): Promise<{
41
- authorizeUrl: string;
42
- codeVerifier: string;
43
- }>;
44
- /** Adds the client's authentication to a token request, as `tokenEndpointAuth` says. */
45
- export declare function addClientAuthentication(client: OAuthClient, headers: Headers, params: URLSearchParams): void;
46
- /** Exchanges the authorization code (with its PKCE verifier) for tokens. */
47
- export declare function exchangeCode(options: {
48
- tokenEndpoint: string;
49
- client: OAuthClient;
50
- code: string;
51
- codeVerifier: string;
52
- redirectUri: string;
53
- resource?: string;
54
- fetchFn: typeof fetch;
55
- }): Promise<OAuthTokens>;
@@ -1,186 +0,0 @@
1
- /**
2
- * The network half of an MCP OAuth connect (F9 C2): discovery, dynamic client registration,
3
- * the authorization URL and the code exchange, with the MCP SDK's auth helpers over the fetch
4
- * it is given (the Host's `guardedFetch` in the gateway). `VaultService.startOAuth` and
5
- * `finishOAuth` call these outside any transaction and hold the secrets; nothing here stores
6
- * or logs one.
7
- */
8
- import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, exchangeAuthorization, registerClient, startAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js";
9
- import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
10
- import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
11
- import { HttpError } from "../tenant/http.js";
12
- import { OutboundFailed, OutboundRefused } from "../tenant/outbound.js";
13
- /** A failed step, as the Tenant API answers it: a refused address is the Host's rule (400). */
14
- export function oauthFailure(step, error) {
15
- if (error instanceof HttpError)
16
- return error;
17
- if (error instanceof OutboundRefused)
18
- return new HttpError(400, `${error.message} (OAuth ${step})`, { code: "request_rejected" });
19
- if (error instanceof OAuthError)
20
- return new HttpError(502, `The authorization server refused the ${step} (${error.errorCode})`, {
21
- code: "oauth_failed",
22
- });
23
- if (error instanceof OutboundFailed)
24
- return new HttpError(502, `The OAuth ${step} failed: ${error.message}`, { code: "oauth_failed" });
25
- return new HttpError(502, `The OAuth ${step} failed: the authorization server's answer was not usable`, {
26
- code: "oauth_failed",
27
- });
28
- }
29
- function httpUrl(value, what) {
30
- let url;
31
- try {
32
- url = new URL(value);
33
- }
34
- catch {
35
- throw new HttpError(502, `The authorization server's ${what} is not a URL`, { code: "oauth_failed" });
36
- }
37
- if (url.protocol !== "https:" && url.protocol !== "http:")
38
- throw new HttpError(502, `The authorization server's ${what} is not an http(s) URL`, {
39
- code: "oauth_failed",
40
- });
41
- return url;
42
- }
43
- /**
44
- * RFC 9728 protected resource metadata, then RFC 8414 (or OpenID) metadata of its first
45
- * authorization server. A server without the former is its own authorization server (the MCP
46
- * spec's fallback); a refused address is never a fallback.
47
- */
48
- export async function discoverOAuthServer(url, fetchFn) {
49
- let authorizationServerUrl;
50
- let resource;
51
- let scope;
52
- try {
53
- const protectedResource = await discoverOAuthProtectedResourceMetadata(url, {}, fetchFn);
54
- authorizationServerUrl = protectedResource.authorization_servers?.[0];
55
- if (protectedResource.resource) {
56
- if (!checkResourceAllowed({ requestedResource: url, configuredResource: protectedResource.resource }))
57
- throw new HttpError(502, `The MCP server's protected resource metadata names another resource (${protectedResource.resource})`, { code: "oauth_failed" });
58
- resource = protectedResource.resource;
59
- }
60
- if (protectedResource.scopes_supported?.length)
61
- scope = protectedResource.scopes_supported.join(" ");
62
- }
63
- catch (error) {
64
- if (error instanceof OutboundRefused || error instanceof HttpError)
65
- throw oauthFailure("discovery", error);
66
- // No RFC 9728 metadata: the MCP server is its own authorization server.
67
- }
68
- authorizationServerUrl ??= new URL("/", url).href;
69
- httpUrl(authorizationServerUrl, "address");
70
- let metadata;
71
- try {
72
- metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn });
73
- }
74
- catch (error) {
75
- throw oauthFailure("discovery", error);
76
- }
77
- if (!metadata)
78
- throw new HttpError(502, `No OAuth authorization server metadata at ${new URL(authorizationServerUrl).origin}: the MCP server does not support OAuth sign-in`, { code: "oauth_failed" });
79
- httpUrl(metadata.authorization_endpoint, "authorization endpoint");
80
- httpUrl(metadata.token_endpoint, "token endpoint");
81
- return {
82
- authorizationServerUrl,
83
- metadata,
84
- ...(resource === undefined ? {} : { resource }),
85
- ...(scope === undefined ? {} : { scope }),
86
- };
87
- }
88
- function authMethodOf(value, secret) {
89
- if (value === undefined)
90
- return secret ? "client_secret_basic" : "none";
91
- if (value === "none" || value === "client_secret_basic" || value === "client_secret_post") {
92
- if (value !== "none" && !secret)
93
- throw new HttpError(502, "The authorization server registered a confidential client without a secret", {
94
- code: "oauth_failed",
95
- });
96
- return value;
97
- }
98
- throw new HttpError(502, `The authorization server's client authentication ${String(value)} is not supported`, {
99
- code: "oauth_failed",
100
- });
101
- }
102
- /**
103
- * The client to sign in as: the operator's `clientId` (a public client) when given, else one
104
- * registered now (RFC 7591) when the server offers registration, else `oauth_client_required`.
105
- */
106
- export async function oauthClient(server, options) {
107
- if (options.clientId)
108
- return { clientId: options.clientId, tokenEndpointAuth: "none" };
109
- if (!server.metadata.registration_endpoint)
110
- throw new HttpError(400, "The authorization server offers no dynamic client registration: register a client with it and pass its id (--client-id)", { code: "oauth_client_required" });
111
- httpUrl(server.metadata.registration_endpoint, "registration endpoint");
112
- try {
113
- const registered = await registerClient(server.authorizationServerUrl, {
114
- metadata: server.metadata,
115
- clientMetadata: {
116
- client_name: "Nylorun",
117
- redirect_uris: [options.redirectUri],
118
- grant_types: ["authorization_code", "refresh_token"],
119
- response_types: ["code"],
120
- token_endpoint_auth_method: "none",
121
- },
122
- ...(server.scope === undefined ? {} : { scope: server.scope }),
123
- fetchFn: options.fetchFn,
124
- });
125
- return {
126
- clientId: registered.client_id,
127
- ...(registered.client_secret ? { clientSecret: registered.client_secret } : {}),
128
- tokenEndpointAuth: authMethodOf(registered.token_endpoint_auth_method, registered.client_secret),
129
- };
130
- }
131
- catch (error) {
132
- throw oauthFailure("client registration", error);
133
- }
134
- }
135
- /** The authorization URL, with a fresh S256 PKCE verifier. */
136
- export async function authorizationUrl(server, client, options) {
137
- try {
138
- const started = await startAuthorization(server.authorizationServerUrl, {
139
- metadata: server.metadata,
140
- clientInformation: { client_id: client.clientId },
141
- redirectUrl: options.redirectUri,
142
- state: options.state,
143
- ...(server.scope === undefined ? {} : { scope: server.scope }),
144
- ...(server.resource === undefined ? {} : { resource: server.resource }),
145
- });
146
- return { authorizeUrl: started.authorizationUrl.href, codeVerifier: started.codeVerifier };
147
- }
148
- catch (error) {
149
- throw oauthFailure("authorization request", error);
150
- }
151
- }
152
- /** Adds the client's authentication to a token request, as `tokenEndpointAuth` says. */
153
- export function addClientAuthentication(client, headers, params) {
154
- if (client.tokenEndpointAuth === "client_secret_basic") {
155
- const encoded = Buffer.from(`${encodeURIComponent(client.clientId)}:${encodeURIComponent(client.clientSecret ?? "")}`).toString("base64");
156
- headers.set("authorization", `Basic ${encoded}`);
157
- return;
158
- }
159
- params.set("client_id", client.clientId);
160
- if (client.tokenEndpointAuth === "client_secret_post")
161
- params.set("client_secret", client.clientSecret ?? "");
162
- }
163
- /** Exchanges the authorization code (with its PKCE verifier) for tokens. */
164
- export async function exchangeCode(options) {
165
- const { client } = options;
166
- const clientInformation = {
167
- client_id: client.clientId,
168
- ...(client.clientSecret ? { client_secret: client.clientSecret } : {}),
169
- };
170
- try {
171
- return await exchangeAuthorization(options.tokenEndpoint, {
172
- // Only the token endpoint is read from it.
173
- metadata: { token_endpoint: options.tokenEndpoint },
174
- clientInformation,
175
- authorizationCode: options.code,
176
- codeVerifier: options.codeVerifier,
177
- redirectUri: options.redirectUri,
178
- ...(options.resource === undefined ? {} : { resource: options.resource }),
179
- addClientAuthentication: (headers, params) => addClientAuthentication(client, headers, params),
180
- fetchFn: options.fetchFn,
181
- });
182
- }
183
- catch (error) {
184
- throw oauthFailure("code exchange", error);
185
- }
186
- }