@agent-native/core 0.154.2 → 0.154.4

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 (43) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
  3. package/corpus/templates/content/actions/_database-utils.ts +11 -3
  4. package/corpus/templates/content/actions/_property-utils.ts +137 -30
  5. package/corpus/templates/content/actions/configure-document-property.ts +5 -0
  6. package/corpus/templates/content/actions/delete-document-property.ts +38 -17
  7. package/corpus/templates/content/actions/delete-document.ts +3 -0
  8. package/corpus/templates/content/actions/edit-document.ts +18 -1
  9. package/corpus/templates/content/actions/export-document.ts +41 -0
  10. package/corpus/templates/content/actions/remove-database-items.ts +10 -0
  11. package/corpus/templates/content/actions/set-document-property.ts +62 -2
  12. package/corpus/templates/content/actions/update-document.ts +24 -0
  13. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
  14. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
  15. package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
  16. package/corpus/templates/content/app/i18n-data.ts +20 -0
  17. package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
  18. package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
  19. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
  20. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
  21. package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
  22. package/corpus/templates/content/server/db/schema.ts +61 -0
  23. package/corpus/templates/content/server/plugins/db.ts +55 -0
  24. package/corpus/templates/content/shared/api.ts +3 -0
  25. package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
  26. package/corpus/templates/content/shared/document-export.ts +33 -1
  27. package/dist/cli/create.js +3 -0
  28. package/dist/collab/awareness.d.ts +2 -2
  29. package/dist/extensions/url-safety.d.ts +7 -2
  30. package/dist/extensions/url-safety.js +27 -12
  31. package/dist/mcp-client/oauth-client.d.ts +16 -1
  32. package/dist/mcp-client/oauth-client.js +177 -83
  33. package/dist/mcp-client/remote-store.js +46 -20
  34. package/dist/oauth-tokens/index.d.ts +2 -1
  35. package/dist/oauth-tokens/index.js +2 -1
  36. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  37. package/dist/oauth-tokens/lifecycle.js +434 -0
  38. package/dist/oauth-tokens/migrations.js +10 -0
  39. package/dist/oauth-tokens/store.d.ts +22 -0
  40. package/dist/oauth-tokens/store.js +141 -18
  41. package/dist/observability/routes.d.ts +1 -1
  42. package/dist/resources/handlers.d.ts +1 -1
  43. package/package.json +2 -1
@@ -225,25 +225,37 @@ function normalizeAllowedPrivateOriginOriginKeys(origins) {
225
225
  }
226
226
  return keys;
227
227
  }
228
- export async function createSsrfSafeDispatcher(allowedPrivateOrigins = []) {
229
- // Keep the optional undici import opaque to Vite/Rolldown. A static
230
- // `import("undici")` makes browser builds try to resolve and bundle undici
231
- // even though this dispatcher is only useful in Node server runtimes.
228
+ export async function createSsrfSafeDispatcher(allowedPrivateOrigins = [], destinationUrl, options = {}) {
229
+ // Keep the undici import opaque to Vite/Rolldown. A literal dynamic import
230
+ // makes browser builds try to resolve and bundle this server-only package.
232
231
  let undici;
233
232
  let dnsModule;
234
233
  try {
235
- const runtimeImport = new Function("specifier", "return import(specifier)");
236
- undici = await runtimeImport("undici");
234
+ const undiciSpecifier = "undici";
235
+ undici = await import(/* @vite-ignore */ undiciSpecifier);
237
236
  dnsModule = await import("node:dns");
238
237
  }
239
- catch {
238
+ catch (error) {
239
+ if (options.required) {
240
+ throw new Error("SSRF protection is unavailable because the server dispatcher could not be loaded.", { cause: error });
241
+ }
240
242
  return null;
241
243
  }
242
244
  const { Agent } = undici;
243
245
  const { lookup } = dnsModule;
244
246
  const allowedPrivateOriginKeys = normalizeAllowedPrivateOriginKeys(allowedPrivateOrigins);
245
- if (!Agent || !lookup)
247
+ let destinationPort = "";
248
+ if (destinationUrl) {
249
+ const parsed = new URL(destinationUrl);
250
+ destinationPort =
251
+ parsed.port || (parsed.protocol === "https:" ? "443" : "80");
252
+ }
253
+ if (!Agent || !lookup) {
254
+ if (options.required) {
255
+ throw new Error("SSRF protection is unavailable because the server dispatcher is incomplete.");
256
+ }
246
257
  return null;
258
+ }
247
259
  return new Agent({
248
260
  connect: {
249
261
  // Override DNS lookup at connect time so the IP we hand to undici's
@@ -257,7 +269,7 @@ export async function createSsrfSafeDispatcher(allowedPrivateOrigins = []) {
257
269
  ? addresses
258
270
  : [{ address: addresses, family: 4 }];
259
271
  for (const record of list) {
260
- const allowedOrigin = allowedPrivateOriginKeys.has(`${normalizeLookupHostname(hostname)}:${String(options?.port ?? "")}`);
272
+ const allowedOrigin = allowedPrivateOriginKeys.has(`${normalizeLookupHostname(hostname)}:${destinationPort}`);
261
273
  if (isPrivateHost(record.address) && !allowedOrigin) {
262
274
  const e = new Error(`Connect blocked: ${hostname} resolved to private address ${record.address}`);
263
275
  e.code = "EAI_BLOCKED";
@@ -285,7 +297,9 @@ export async function createSsrfSafeDispatcher(allowedPrivateOrigins = []) {
285
297
  * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
286
298
  * on the initial URL and on every redirect hop.
287
299
  * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
288
- * time (closes the DNS-rebinding TOCTOU) when undici is available.
300
+ * time (closes the DNS-rebinding TOCTOU) when the runtime supports the
301
+ * Node dispatcher. Edge runtimes retain preflight DNS checks, literal and
302
+ * rebinding-domain rejection, and per-hop redirect validation.
289
303
  * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
290
304
  * private network because each hop is re-validated before it is followed.
291
305
  *
@@ -304,8 +318,6 @@ export async function createSsrfSafeDispatcher(allowedPrivateOrigins = []) {
304
318
  */
305
319
  export async function ssrfSafeFetch(url, init = {}, options = {}) {
306
320
  const maxRedirects = options.maxRedirects ?? 3;
307
- const dispatcher = (await createSsrfSafeDispatcher(options.allowedPrivateOrigins)) ??
308
- undefined;
309
321
  const allowedPrivateOrigins = normalizeAllowedPrivateOriginOriginKeys(options.allowedPrivateOrigins ?? []);
310
322
  const isAllowedPrivateOrigin = (candidate) => {
311
323
  if (allowedPrivateOrigins.size === 0)
@@ -333,10 +345,13 @@ export async function ssrfSafeFetch(url, init = {}, options = {}) {
333
345
  ...init,
334
346
  redirect: "manual",
335
347
  };
348
+ const dispatcher = await createSsrfSafeDispatcher(options.allowedPrivateOrigins, currentUrl);
336
349
  if (dispatcher)
337
350
  fetchOpts.dispatcher = dispatcher;
338
351
  const response = await fetch(currentUrl, fetchOpts);
339
352
  if (response.status >= 300 && response.status < 400) {
353
+ if (options.followRedirects === false)
354
+ return response;
340
355
  const location = response.headers.get("location");
341
356
  if (!location)
342
357
  return response;
@@ -7,8 +7,9 @@
7
7
  * and refresh boundary.
8
8
  */
9
9
  import { type AuthorizationServerMetadata, type OAuthClientInformationContext, type OAuthClientMetadata, type OAuthClientProvider, type OAuthDiscoveryState, OAuthProtectedResourceMetadata, type StoredOAuthClientInformation, type StoredOAuthTokens, OAuthTokens } from "@modelcontextprotocol/client";
10
+ import { type OAuthCredential, type OAuthCredentialState, type OAuthRevocationResult } from "../oauth-tokens/lifecycle.js";
10
11
  export type McpOAuthDiscoveryState = OAuthDiscoveryState;
11
- export interface McpOAuthCredentialBundle {
12
+ export interface McpOAuthCredentialBundle extends OAuthCredential {
12
13
  serverUrl: string;
13
14
  clientInformation: StoredOAuthClientInformation;
14
15
  discoveryState?: McpOAuthDiscoveryState;
@@ -89,12 +90,26 @@ export declare function readMcpOAuthCredentials(options: {
89
90
  key: string;
90
91
  scope: "user" | "org";
91
92
  scopeId: string;
93
+ serverUrl?: string;
92
94
  }): Promise<McpOAuthCredentialBundle | null>;
95
+ export declare function getMcpOAuthConnectionState(options: {
96
+ key: string;
97
+ scope: "user" | "org";
98
+ scopeId: string;
99
+ serverUrl: string;
100
+ }): Promise<OAuthCredentialState<McpOAuthCredentialBundle>>;
93
101
  export declare function deleteMcpOAuthCredentials(options: {
94
102
  key: string;
95
103
  scope: "user" | "org";
96
104
  scopeId: string;
105
+ serverUrl?: string;
97
106
  }): Promise<boolean>;
107
+ export declare function revokeMcpOAuthCredentials(options: {
108
+ key: string;
109
+ scope: "user" | "org";
110
+ scopeId: string;
111
+ serverUrl: string;
112
+ }): Promise<OAuthRevocationResult>;
98
113
  /**
99
114
  * Resolve an access token for the MCP manager. Refreshing happens only when a
100
115
  * token is near expiry, so ordinary manager reconfiguration does not perform
@@ -7,10 +7,12 @@
7
7
  * and refresh boundary.
8
8
  */
9
9
  import { auth, refreshAuthorization, validateAuthorizationResponseIssuer, } from "@modelcontextprotocol/client";
10
- import { deleteOAuthTokens, getOAuthTokens, saveOAuthTokens, } from "../oauth-tokens/store.js";
10
+ import { ssrfSafeFetch } from "../extensions/url-safety.js";
11
+ import { readOAuthCredentialState, resolveOAuthCredentialAccess, revokeOAuthCredential, saveOAuthCredential, } from "../oauth-tokens/lifecycle.js";
11
12
  import { validateRemoteUrl } from "./remote-url.js";
12
13
  const TOKEN_EXPIRY_SKEW_MS = 60_000;
13
14
  const MAX_OAUTH_REDIRECTS = 5;
15
+ const MCP_OAUTH_PRIVATE_ORIGINS_ENV = "AGENT_NATIVE_MCP_OAUTH_PRIVATE_ORIGINS";
14
16
  function checkedRemoteUrl(value, label) {
15
17
  const result = validateRemoteUrl(String(value));
16
18
  if (!result.ok || !result.url) {
@@ -18,12 +20,35 @@ function checkedRemoteUrl(value, label) {
18
20
  }
19
21
  return result.url;
20
22
  }
23
+ function canonicalServerUrl(value) {
24
+ return checkedRemoteUrl(value, "server").toString();
25
+ }
26
+ function serverUrlsMatch(stored, canonical) {
27
+ try {
28
+ return canonicalServerUrl(stored) === canonical;
29
+ }
30
+ catch {
31
+ // coercion-ok: an invalid stored URL is an explicit identity mismatch.
32
+ return false;
33
+ }
34
+ }
35
+ function configuredPrivateOrigins() {
36
+ return (process.env[MCP_OAUTH_PRIVATE_ORIGINS_ENV] ?? "")
37
+ .split(",")
38
+ .map((origin) => origin.trim())
39
+ .filter(Boolean);
40
+ }
21
41
  function guardedOAuthFetch() {
42
+ const allowedPrivateOrigins = configuredPrivateOrigins();
22
43
  return async (url, init) => {
23
44
  let currentUrl = checkedRemoteUrl(url, "request");
24
45
  let currentInit = { ...init, redirect: "manual" };
25
46
  for (let redirectCount = 0;; redirectCount += 1) {
26
- const response = await fetch(currentUrl, currentInit);
47
+ const response = await ssrfSafeFetch(currentUrl.href, currentInit, {
48
+ maxRedirects: 0,
49
+ followRedirects: false,
50
+ allowedPrivateOrigins,
51
+ });
27
52
  if (![301, 302, 303, 307, 308].includes(response.status)) {
28
53
  return response;
29
54
  }
@@ -42,10 +67,15 @@ function guardedOAuthFetch() {
42
67
  }
43
68
  await response.body?.cancel().catch(() => undefined);
44
69
  const nextHeaders = new Headers(currentInit.headers);
45
- if (nextUrl.origin !== currentUrl.origin) {
70
+ const changesOrigin = nextUrl.origin !== currentUrl.origin;
71
+ if (changesOrigin) {
46
72
  nextHeaders.delete("authorization");
47
73
  nextHeaders.delete("cookie");
48
74
  nextHeaders.delete("proxy-authorization");
75
+ if ((response.status === 307 || response.status === 308) &&
76
+ currentInit.body != null) {
77
+ throw new Error("MCP OAuth redirect cannot forward a request body across origins.");
78
+ }
49
79
  }
50
80
  const nextInit = {
51
81
  ...currentInit,
@@ -64,6 +94,19 @@ function guardedOAuthFetch() {
64
94
  }
65
95
  };
66
96
  }
97
+ async function guardedRevocationFetch(url, init) {
98
+ const endpoint = checkedRemoteUrl(url, "revocation endpoint");
99
+ return ssrfSafeFetch(endpoint.href, init, {
100
+ maxRedirects: 0,
101
+ allowedPrivateOrigins: configuredPrivateOrigins(),
102
+ assertUrlAllowed: (candidate) => {
103
+ const checked = checkedRemoteUrl(candidate, "revocation redirect");
104
+ if (checked.origin !== endpoint.origin) {
105
+ throw new Error("MCP OAuth revocation redirect changed origin.");
106
+ }
107
+ },
108
+ });
109
+ }
67
110
  function validateDiscoveryUrls(state) {
68
111
  checkedRemoteUrl(state.authorizationServerUrl, "authorization server");
69
112
  if (state.resourceMetadataUrl) {
@@ -85,13 +128,18 @@ function validateDiscoveryUrls(state) {
85
128
  }
86
129
  }
87
130
  }
88
- function credentialOwner(options) {
89
- return `${options.scope}:${options.scopeId}`;
90
- }
91
131
  function issuerForDiscovery(discovery) {
92
132
  const issuer = discovery?.authorizationServerMetadata?.issuer;
93
133
  return typeof issuer === "string" && issuer ? issuer : undefined;
94
134
  }
135
+ function credentialIdentity(options) {
136
+ return {
137
+ provider: "mcp",
138
+ accountId: options.key,
139
+ resource: options.serverUrl,
140
+ owner: { scope: options.scope, id: options.scopeId },
141
+ };
142
+ }
95
143
  function withIssuer(value, context) {
96
144
  if (value.issuer && context?.issuer && value.issuer !== context.issuer) {
97
145
  throw new Error("MCP OAuth credential issuer does not match discovery");
@@ -270,24 +318,28 @@ export function tokenExpiresAt(tokens) {
270
318
  return Date.now() + expiresIn * 1_000;
271
319
  }
272
320
  export async function saveMcpOAuthCredentials(options) {
273
- checkedRemoteUrl(options.credentials.serverUrl, "server");
274
- if (options.credentials.discoveryState) {
275
- validateDiscoveryUrls(options.credentials.discoveryState);
276
- }
277
- await saveOAuthTokens("mcp", options.key, options.credentials, `${options.scope}:${options.scopeId}`);
321
+ const serverUrl = checkedRemoteUrl(options.credentials.serverUrl, "server").toString();
322
+ const credentials = { ...options.credentials, serverUrl };
323
+ if (credentials.discoveryState) {
324
+ validateDiscoveryUrls(credentials.discoveryState);
325
+ }
326
+ await saveOAuthCredential(credentialIdentity({
327
+ ...options,
328
+ serverUrl,
329
+ }), credentials, { legacyAccountKey: true });
278
330
  }
279
331
  export async function readMcpOAuthCredentials(options) {
280
- const stored = await getOAuthTokens("mcp", options.key, credentialOwner(options));
281
- if (!stored)
332
+ if (!options.serverUrl)
282
333
  return null;
283
- const parsed = stored;
284
- if (typeof parsed.serverUrl !== "string" ||
285
- !parsed.clientInformation ||
286
- !parsed.tokens ||
287
- typeof parsed.tokens.access_token !== "string") {
334
+ const serverUrl = canonicalServerUrl(options.serverUrl);
335
+ const state = await getMcpOAuthConnectionState({
336
+ ...options,
337
+ serverUrl,
338
+ });
339
+ if (state.kind === "missing" || state.kind === "malformed")
288
340
  return null;
289
- }
290
- if (!validateRemoteUrl(parsed.serverUrl).ok)
341
+ const parsed = state.credential;
342
+ if (!serverUrlsMatch(parsed.serverUrl, serverUrl))
291
343
  return null;
292
344
  if (parsed.discoveryState) {
293
345
  try {
@@ -297,10 +349,63 @@ export async function readMcpOAuthCredentials(options) {
297
349
  return null;
298
350
  }
299
351
  }
300
- return parsed;
352
+ return { ...parsed, serverUrl };
353
+ }
354
+ export async function getMcpOAuthConnectionState(options) {
355
+ const serverUrl = canonicalServerUrl(options.serverUrl);
356
+ return readOAuthCredentialState(credentialIdentity({ ...options, serverUrl }), {
357
+ allowLegacy: true,
358
+ legacyAccountKey: true,
359
+ validateCredential: (credential) => serverUrlsMatch(credential.serverUrl, serverUrl),
360
+ });
301
361
  }
302
362
  export async function deleteMcpOAuthCredentials(options) {
303
- return ((await deleteOAuthTokens("mcp", options.key, credentialOwner(options))) > 0);
363
+ if (!options.serverUrl)
364
+ return false;
365
+ const serverUrl = canonicalServerUrl(options.serverUrl);
366
+ const identity = credentialIdentity({
367
+ ...options,
368
+ serverUrl,
369
+ });
370
+ const result = await revokeOAuthCredential(identity, {
371
+ allowLegacy: true,
372
+ legacyAccountKey: true,
373
+ validateCredential: (credential) => serverUrlsMatch(credential.serverUrl, serverUrl),
374
+ });
375
+ return result.local === "deleted";
376
+ }
377
+ export async function revokeMcpOAuthCredentials(options) {
378
+ const serverUrl = canonicalServerUrl(options.serverUrl);
379
+ const identity = credentialIdentity({ ...options, serverUrl });
380
+ return revokeOAuthCredential(identity, {
381
+ allowLegacy: true,
382
+ legacyAccountKey: true,
383
+ validateCredential: (credential) => serverUrlsMatch(credential.serverUrl, serverUrl),
384
+ revoke: async ({ credential }) => {
385
+ const endpoint = credential.discoveryState?.authorizationServerMetadata?.revocation_endpoint;
386
+ if (!endpoint)
387
+ return "unsupported";
388
+ const token = credential.tokens.refresh_token ?? credential.tokens.access_token;
389
+ const body = new URLSearchParams({
390
+ token,
391
+ token_type_hint: credential.tokens.refresh_token
392
+ ? "refresh_token"
393
+ : "access_token",
394
+ client_id: credential.clientInformation.client_id,
395
+ });
396
+ const response = await guardedRevocationFetch(endpoint, {
397
+ method: "POST",
398
+ headers: { "content-type": "application/x-www-form-urlencoded" },
399
+ body,
400
+ });
401
+ if (!response.ok) {
402
+ await response.body?.cancel().catch(() => undefined);
403
+ throw new Error("MCP OAuth revocation failed.");
404
+ }
405
+ await response.body?.cancel().catch(() => undefined);
406
+ return "succeeded";
407
+ },
408
+ });
304
409
  }
305
410
  /**
306
411
  * Resolve an access token for the MCP manager. Refreshing happens only when a
@@ -308,66 +413,55 @@ export async function deleteMcpOAuthCredentials(options) {
308
413
  * a network request for every connector.
309
414
  */
310
415
  export async function getMcpOAuthAccessToken(options) {
311
- if (!validateRemoteUrl(options.serverUrl).ok)
312
- return null;
313
- const credentials = await readMcpOAuthCredentials(options);
314
- if (!credentials || credentials.serverUrl !== options.serverUrl)
416
+ const validation = validateRemoteUrl(options.serverUrl);
417
+ if (!validation.ok || !validation.url)
315
418
  return null;
316
- const accessToken = credentials.tokens.access_token;
317
- const now = Date.now();
318
- if (typeof credentials.tokenExpiresAt !== "number" ||
319
- credentials.tokenExpiresAt - now > TOKEN_EXPIRY_SKEW_MS) {
320
- return accessToken;
321
- }
322
- const tokenIsExpired = credentials.tokenExpiresAt <= now;
323
- const refreshToken = credentials.tokens.refresh_token;
324
- const discovery = credentials.discoveryState;
325
- if (!refreshToken || !discovery?.authorizationServerUrl) {
326
- return tokenIsExpired ? null : accessToken;
327
- }
328
- try {
329
- const expectedIssuer = issuerForDiscovery(discovery);
330
- if (!expectedIssuer ||
331
- credentials.clientInformation.issuer !== expectedIssuer ||
332
- credentials.tokens.issuer !== expectedIssuer) {
333
- return null;
334
- }
335
- const resource = discovery.resourceMetadata?.resource
336
- ? checkedRemoteUrl(discovery.resourceMetadata.resource, "resource")
337
- : undefined;
338
- const authorizationServerUrl = checkedRemoteUrl(discovery.authorizationServerUrl, "authorization server");
339
- const refreshed = await refreshAuthorization(authorizationServerUrl, {
340
- metadata: discovery.authorizationServerMetadata,
341
- clientInformation: credentials.clientInformation,
342
- refreshToken,
343
- resource,
344
- fetchFn: guardedOAuthFetch(),
345
- });
346
- const nextTokens = {
347
- ...credentials.tokens,
348
- ...refreshed,
349
- issuer: expectedIssuer,
350
- ...(refreshed.refresh_token
351
- ? { refresh_token: refreshed.refresh_token }
352
- : credentials.tokens.refresh_token
353
- ? { refresh_token: credentials.tokens.refresh_token }
354
- : {}),
355
- };
356
- const next = {
357
- ...credentials,
358
- tokens: nextTokens,
359
- tokenExpiresAt: tokenExpiresAt(nextTokens),
360
- };
361
- await saveMcpOAuthCredentials({
362
- ...options,
363
- credentials: next,
364
- });
365
- return nextTokens.access_token;
366
- }
367
- catch {
368
- // A still-valid token can survive a transient refresh failure. Once it has
369
- // expired, omit it so callers surface reauthorization instead of retrying
370
- // a credential that can never authenticate.
371
- return tokenIsExpired ? null : accessToken;
372
- }
419
+ const serverUrl = validation.url.toString();
420
+ const result = await resolveOAuthCredentialAccess(credentialIdentity({ ...options, serverUrl }), {
421
+ allowLegacy: true,
422
+ legacyAccountKey: true,
423
+ validateCredential: (credential) => serverUrlsMatch(credential.serverUrl, serverUrl),
424
+ expirySkewMs: TOKEN_EXPIRY_SKEW_MS,
425
+ refresh: async ({ credential: credentials }) => {
426
+ const refreshToken = credentials.tokens.refresh_token;
427
+ const discovery = credentials.discoveryState;
428
+ if (!refreshToken || !discovery?.authorizationServerUrl) {
429
+ throw new Error("MCP OAuth refresh is unavailable.");
430
+ }
431
+ const expectedIssuer = issuerForDiscovery(discovery);
432
+ if (!expectedIssuer ||
433
+ credentials.clientInformation.issuer !== expectedIssuer ||
434
+ credentials.tokens.issuer !== expectedIssuer) {
435
+ throw new Error("MCP OAuth refresh issuer binding is invalid.");
436
+ }
437
+ const resource = discovery.resourceMetadata?.resource
438
+ ? checkedRemoteUrl(discovery.resourceMetadata.resource, "resource")
439
+ : undefined;
440
+ const authorizationServerUrl = checkedRemoteUrl(discovery.authorizationServerUrl, "authorization server");
441
+ const refreshed = await refreshAuthorization(authorizationServerUrl, {
442
+ metadata: discovery.authorizationServerMetadata,
443
+ clientInformation: credentials.clientInformation,
444
+ refreshToken,
445
+ resource,
446
+ fetchFn: guardedOAuthFetch(),
447
+ });
448
+ const nextTokens = {
449
+ ...credentials.tokens,
450
+ ...refreshed,
451
+ issuer: expectedIssuer,
452
+ ...(refreshed.refresh_token
453
+ ? { refresh_token: refreshed.refresh_token }
454
+ : credentials.tokens.refresh_token
455
+ ? { refresh_token: credentials.tokens.refresh_token }
456
+ : {}),
457
+ };
458
+ const next = {
459
+ ...credentials,
460
+ tokens: nextTokens,
461
+ tokenExpiresAt: tokenExpiresAt(nextTokens),
462
+ };
463
+ return next;
464
+ },
465
+ });
466
+ return result.accessToken;
373
467
  }
@@ -23,7 +23,7 @@ import { createHash } from "node:crypto";
23
23
  import { writeAppSecret, readAppSecret, deleteAppSecret, } from "../secrets/storage.js";
24
24
  import { getOrgSetting, putOrgSetting, deleteOrgSetting, } from "../settings/org-settings.js";
25
25
  import { getUserSetting, putUserSetting, deleteUserSetting, } from "../settings/user-settings.js";
26
- import { deleteMcpOAuthCredentials, getMcpOAuthAccessToken, saveMcpOAuthCredentials, } from "./oauth-client.js";
26
+ import { getMcpOAuthAccessToken, revokeMcpOAuthCredentials, saveMcpOAuthCredentials, } from "./oauth-client.js";
27
27
  import { validateRemoteUrl } from "./remote-url.js";
28
28
  export { validateRemoteUrl } from "./remote-url.js";
29
29
  const SETTINGS_KEY = "mcp-servers-remote";
@@ -140,35 +140,53 @@ export async function addRemoteServer(scope, scopeId, input) {
140
140
  * stored separately from the settings row and never returned to clients.
141
141
  */
142
142
  export async function addOAuthRemoteServer(scope, scopeId, input) {
143
+ const serverUrl = validateRemoteUrl(input.url);
144
+ const credentialResource = validateRemoteUrl(input.credentials.serverUrl);
145
+ if (!serverUrl.ok ||
146
+ !serverUrl.url ||
147
+ !credentialResource.ok ||
148
+ !credentialResource.url ||
149
+ serverUrl.url.toString() !== credentialResource.url.toString()) {
150
+ return {
151
+ ok: false,
152
+ error: "MCP server URL must match the OAuth credential resource URL",
153
+ };
154
+ }
155
+ const credentials = {
156
+ ...input.credentials,
157
+ serverUrl: credentialResource.url.toString(),
158
+ };
143
159
  const oauthSecretKey = `mcp_oauth:${shortId()}`;
144
160
  try {
145
161
  await saveMcpOAuthCredentials({
146
162
  key: oauthSecretKey,
147
163
  scope,
148
164
  scopeId,
149
- credentials: input.credentials,
165
+ credentials,
150
166
  });
151
167
  const result = await addRemoteServerInternal(scope, scopeId, {
152
168
  name: input.name,
153
- url: input.url,
169
+ url: credentials.serverUrl,
154
170
  description: input.description,
155
171
  oauthSecretKey,
156
172
  });
157
173
  if (!result.ok) {
158
- await deleteMcpOAuthCredentials({
174
+ await revokeMcpOAuthCredentials({
159
175
  key: oauthSecretKey,
160
176
  scope,
161
177
  scopeId,
162
- });
178
+ serverUrl: credentials.serverUrl,
179
+ }).catch(() => undefined);
163
180
  }
164
181
  return result;
165
182
  }
166
183
  catch (err) {
167
- await deleteMcpOAuthCredentials({
184
+ await revokeMcpOAuthCredentials({
168
185
  key: oauthSecretKey,
169
186
  scope,
170
187
  scopeId,
171
- }).catch(() => { });
188
+ serverUrl: credentials.serverUrl,
189
+ }).catch(() => undefined);
172
190
  return {
173
191
  ok: false,
174
192
  error: `Failed to save MCP OAuth credentials: ${err?.message ?? err}`,
@@ -311,6 +329,27 @@ export async function removeRemoteServer(scope, scopeId, id) {
311
329
  const next = existing.filter((s) => s.id !== id);
312
330
  if (next.length === existing.length)
313
331
  return false;
332
+ if (removed?.oauthSecretKey) {
333
+ try {
334
+ const result = await revokeMcpOAuthCredentials({
335
+ key: removed.oauthSecretKey,
336
+ scope,
337
+ scopeId,
338
+ serverUrl: removed.url,
339
+ });
340
+ if (result.local === "replaced") {
341
+ console.warn(`[mcp-client] MCP OAuth credentials changed while removing ${removed.name}; the server was kept so the newer connection remains manageable.`);
342
+ return false;
343
+ }
344
+ if (result.remote === "failed") {
345
+ console.warn(`[mcp-client] MCP OAuth revocation failed for ${removed.name}; local credentials were removed.`);
346
+ }
347
+ }
348
+ catch (err) {
349
+ // eslint-disable-next-line no-console
350
+ console.warn(`[mcp-client] Failed to delete MCP OAuth credentials ${removed.oauthSecretKey}: ${err?.message ?? err}`);
351
+ }
352
+ }
314
353
  if (next.length === 0) {
315
354
  if (scope === "user") {
316
355
  await deleteUserSetting(scopeId, SETTINGS_KEY);
@@ -338,19 +377,6 @@ export async function removeRemoteServer(scope, scopeId, id) {
338
377
  console.warn(`[mcp-client] Failed to delete MCP header secret ${removed.headerSecretKey}: ${err?.message ?? err}`);
339
378
  }
340
379
  }
341
- if (removed?.oauthSecretKey) {
342
- try {
343
- await deleteMcpOAuthCredentials({
344
- key: removed.oauthSecretKey,
345
- scope,
346
- scopeId,
347
- });
348
- }
349
- catch (err) {
350
- // eslint-disable-next-line no-console
351
- console.warn(`[mcp-client] Failed to delete MCP OAuth credentials ${removed.oauthSecretKey}: ${err?.message ?? err}`);
352
- }
353
- }
354
380
  return true;
355
381
  }
356
382
  /**
@@ -1,2 +1,3 @@
1
- export { getOAuthTokens, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, deleteOAuthTokens, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
1
+ export { getOAuthTokens, getOAuthTokenSnapshot, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, replaceOAuthTokensIfRevision, deleteOAuthTokens, deleteOAuthTokensIfRevision, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
2
+ export { readOAuthCredentialState, resolveOAuthCredentialAccess, revokeOAuthCredential, saveOAuthCredential, type OAuthCredential, type OAuthCredentialIdentity, type OAuthCredentialOwner, type OAuthCredentialState, type OAuthRevocationResult, } from "./lifecycle.js";
2
3
  export { refreshExpiringGoogleTokens, startGoogleTokenRefreshLoop, } from "./google-refresh.js";
@@ -1,2 +1,3 @@
1
- export { getOAuthTokens, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, deleteOAuthTokens, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
1
+ export { getOAuthTokens, getOAuthTokenSnapshot, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, replaceOAuthTokensIfRevision, deleteOAuthTokens, deleteOAuthTokensIfRevision, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
2
+ export { readOAuthCredentialState, resolveOAuthCredentialAccess, revokeOAuthCredential, saveOAuthCredential, } from "./lifecycle.js";
2
3
  export { refreshExpiringGoogleTokens, startGoogleTokenRefreshLoop, } from "./google-refresh.js";