@agent-native/core 0.154.3 → 0.154.5

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 (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/.agents/skills/content/SKILL.md +2 -2
  3. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
  4. package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
  5. package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
  6. package/corpus/templates/content/actions/_database-utils.ts +55 -3
  7. package/corpus/templates/content/actions/_property-utils.ts +141 -31
  8. package/corpus/templates/content/actions/add-database-item.ts +62 -240
  9. package/corpus/templates/content/actions/configure-document-property.ts +170 -1
  10. package/corpus/templates/content/actions/delete-document-property.ts +56 -17
  11. package/corpus/templates/content/actions/delete-document.ts +3 -0
  12. package/corpus/templates/content/actions/edit-document.ts +18 -1
  13. package/corpus/templates/content/actions/export-document.ts +41 -0
  14. package/corpus/templates/content/actions/remove-database-items.ts +10 -0
  15. package/corpus/templates/content/actions/set-document-property.ts +140 -3
  16. package/corpus/templates/content/actions/update-database-item.ts +62 -0
  17. package/corpus/templates/content/actions/update-document.ts +24 -0
  18. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
  19. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
  20. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +10 -8
  22. package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
  23. package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
  24. package/corpus/templates/content/app/i18n-data.ts +20 -0
  25. package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
  26. package/corpus/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md +6 -0
  27. package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
  28. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
  29. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
  30. package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
  31. package/corpus/templates/content/parity/matrix.md +1 -1
  32. package/corpus/templates/content/parity/matrix.ts +2 -0
  33. package/corpus/templates/content/server/db/schema.ts +100 -3
  34. package/corpus/templates/content/server/plugins/db.ts +85 -0
  35. package/corpus/templates/content/shared/api.ts +78 -2
  36. package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
  37. package/corpus/templates/content/shared/document-export.ts +33 -1
  38. package/dist/action.d.ts +22 -0
  39. package/dist/action.js +27 -0
  40. package/dist/collab/awareness.d.ts +2 -2
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/extensions/url-safety.d.ts +7 -2
  43. package/dist/extensions/url-safety.js +27 -12
  44. package/dist/index.browser.d.ts +1 -1
  45. package/dist/index.browser.js +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mcp-client/oauth-client.d.ts +16 -1
  49. package/dist/mcp-client/oauth-client.js +177 -83
  50. package/dist/mcp-client/remote-store.js +46 -20
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/oauth-tokens/index.d.ts +2 -1
  53. package/dist/oauth-tokens/index.js +2 -1
  54. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  55. package/dist/oauth-tokens/lifecycle.js +434 -0
  56. package/dist/oauth-tokens/migrations.js +10 -0
  57. package/dist/oauth-tokens/store.d.ts +22 -0
  58. package/dist/oauth-tokens/store.js +141 -18
  59. package/dist/observability/routes.d.ts +2 -2
  60. package/dist/progress/routes.d.ts +1 -1
  61. package/dist/secrets/routes.d.ts +6 -6
  62. package/dist/server/action-routes.js +10 -2
  63. package/dist/server/realtime-token.d.ts +1 -1
  64. package/package.json +2 -1
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -5,7 +5,9 @@ export declare function isBlockedExtensionUrl(url: string): boolean;
5
5
  * "public hostname resolves to a private address" gap before dispatch.
6
6
  */
7
7
  export declare function isBlockedExtensionUrlWithDns(url: string): Promise<boolean>;
8
- export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonly string[]): Promise<unknown | null>;
8
+ export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonly string[], destinationUrl?: string, options?: {
9
+ required?: boolean;
10
+ }): Promise<unknown>;
9
11
  /**
10
12
  * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
11
13
  *
@@ -14,7 +16,9 @@ export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonl
14
16
  * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
15
17
  * on the initial URL and on every redirect hop.
16
18
  * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
17
- * time (closes the DNS-rebinding TOCTOU) when undici is available.
19
+ * time (closes the DNS-rebinding TOCTOU) when the runtime supports the
20
+ * Node dispatcher. Edge runtimes retain preflight DNS checks, literal and
21
+ * rebinding-domain rejection, and per-hop redirect validation.
18
22
  * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
19
23
  * private network because each hop is re-validated before it is followed.
20
24
  *
@@ -33,6 +37,7 @@ export declare function createSsrfSafeDispatcher(allowedPrivateOrigins?: readonl
33
37
  */
34
38
  export declare function ssrfSafeFetch(url: string, init?: RequestInit, options?: {
35
39
  maxRedirects?: number;
40
+ followRedirects?: boolean;
36
41
  httpsOnly?: boolean;
37
42
  assertUrlAllowed?: (url: string) => void | Promise<void>;
38
43
  /**
@@ -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;
@@ -2,7 +2,7 @@ export * from "./config.js";
2
2
  export { addContextToAgentChat, appendAgentChatContextToMessage, clearAgentChatContext, formatAgentChatContextItemsForPrompt, listAgentChatContext, refreshAgentChatContext, removeAgentChatContextItem, sendToAgentChat, sendToAgentChatAndConfirm, setAgentChatContextItem, setContextToAgentChat, isEmbedMcpChatBridgeActive, useAgentChatContext, useAgentChatGenerating, useDevMode, useSendToAgentChat, AGENT_NATIVE_MCP_APP_HOST_MESSAGE_TYPES, getMcpAppHostContext, openMcpAppHostLink, requestMcpAppDisplayMode, sendMcpAppHostMessage, updateMcpAppModelContext, useMcpAppHostContext, CodeRequiredDialog, AgentNativeI18nProvider, LanguagePicker, getLocaleInitScript, localeDirection, normalizeLocaleCode, normalizeLocalePreference, normalizeLocalizationPreference, resolveLocaleFromCandidates, resolveLocaleFromPreference, useFormatters, useLocale, useT, DEFAULT_LOCALE, LOCALE_HYDRATION_GLOBAL, LOCALE_METADATA, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, useDbSync, useFileWatcher, useSession, cn, ApiKeySettings, type AgentChatContextItem, type AgentChatContextMessage, type AgentChatContextMutationOptions, type AgentChatContextRemoveOptions, type AgentChatContextSetOptions, type AgentChatContextState, type AgentChatMessage, type AgentNativeMcpAppHostMessageType, type McpAppDisplayMode, type McpAppHostChatMessage, type McpAppHostCapabilities, type McpAppHostContext, type McpAppHostContextSnapshot, type McpAppModelContextContentPart, type McpAppModelContextUpdate, type CodeRequiredDialogProps, type AuthSession, type UseAgentChatContextResult, type AgentNativeI18nCatalog, type AgentNativeI18nProviderProps, type LocaleCode, type LocaleHydrationPayload, type LocaleMessages, type LocaleMetadata, type LocalePreference, type LocalizationPreference, } from "./client/index.js";
3
3
  export { agentChat } from "./shared/index.js";
4
4
  export { parseArgs, camelCaseArgs } from "./scripts/parse-args.js";
5
- export { defineAction, AgentActionStopError, isAgentActionStopError, type ActionHttpConfig, type AgentActionStopOptions, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, type ActionMcpAppConfig, type ActionMcpAppCsp, type ActionMcpAppCspBuilder, type ActionMcpAppHtmlBuilder, type ActionMcpAppPermissions, type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js";
5
+ export { defineAction, ActionContractError, isActionContractError, type ActionContractErrorOptions, AgentActionStopError, isAgentActionStopError, type ActionHttpConfig, type AgentActionStopOptions, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, type ActionMcpAppConfig, type ActionMcpAppCsp, type ActionMcpAppCspBuilder, type ActionMcpAppHtmlBuilder, type ActionMcpAppPermissions, type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js";
6
6
  export { normalizeUserProfileName, USER_PROFILE_SETTING_KEY, type UserProfile, } from "./user-profile/index.js";
7
7
  export { ACTION_CHAT_UI_DATA_CHART_RENDERER, ACTION_CHAT_UI_DATA_INSIGHTS_RENDERER, ACTION_CHAT_UI_DATA_TABLE_RENDERER, ACTION_CHAT_UI_DATA_WIDGET_RENDERER, ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER, ACTION_CHAT_UI_WORKSPACE_FILE_RENDERER, type ActionChatUIConfig, } from "./action-ui.js";
8
8
  export { DATA_CHART_WIDGET, DATA_INSIGHTS_WIDGET, DATA_TABLE_WIDGET, createDataChartWidgetResult, createDataInsightsWidgetResult, createDataTableWidgetResult, dataChartWidgetResultSchema, dataChartWidgetSchema, dataInsightsWidgetResultSchema, dataTableWidgetResultSchema, dataTableWidgetSchema, dataWidgetResultSchema, isDataChartWidget, isDataTableWidget, isDataWidgetResult, normalizeDataWidgetKind, normalizeDataWidgetResult, type DataChartSeriesDefinition, type DataChartWidget, type DataChartWidgetResult, type DataChartWidgetResultInput, type DataInsightsWidgetResult, type DataInsightsWidgetResultInput, type DataTableColumn, type DataTableWidget, type DataTableWidgetResult, type DataTableWidgetResultInput, type DataWidgetDisplay, type DataWidgetKind, type DataWidgetResult, type DataWidgetResultMetadata, } from "./data-widgets/index.js";
@@ -9,7 +9,7 @@ export { agentChat } from "./shared/index.js";
9
9
  // Pure utilities (no Node.js deps — safe for browser and SSR)
10
10
  export { parseArgs, camelCaseArgs } from "./scripts/parse-args.js";
11
11
  // defineAction — used by template actions, no Node.js deps
12
- export { defineAction, AgentActionStopError, isAgentActionStopError, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, } from "./action.js";
12
+ export { defineAction, ActionContractError, isActionContractError, AgentActionStopError, isAgentActionStopError, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, } from "./action.js";
13
13
  export { normalizeUserProfileName, USER_PROFILE_SETTING_KEY, } from "./user-profile/index.js";
14
14
  export { ACTION_CHAT_UI_DATA_CHART_RENDERER, ACTION_CHAT_UI_DATA_INSIGHTS_RENDERER, ACTION_CHAT_UI_DATA_TABLE_RENDERER, ACTION_CHAT_UI_DATA_WIDGET_RENDERER, ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER, ACTION_CHAT_UI_WORKSPACE_FILE_RENDERER, } from "./action-ui.js";
15
15
  export { DATA_CHART_WIDGET, DATA_INSIGHTS_WIDGET, DATA_TABLE_WIDGET, createDataChartWidgetResult, createDataInsightsWidgetResult, createDataTableWidgetResult, dataChartWidgetResultSchema, dataChartWidgetSchema, dataInsightsWidgetResultSchema, dataTableWidgetResultSchema, dataTableWidgetSchema, dataWidgetResultSchema, isDataChartWidget, isDataTableWidget, isDataWidgetResult, normalizeDataWidgetKind, normalizeDataWidgetResult, } from "./data-widgets/index.js";
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from "./config.js";
2
2
  export { createProductionAgentHandler, type ActionEntry, type ScriptEntry, type ProductionAgentOptions, type ActionTool, type ScriptTool, type AgentMessage, type AgentChatRequest, type AgentChatEvent, type AgentChatAttachment, DEFAULT_MODEL, TripWire, type Processor, type ProcessorState, type ProcessorAbort, } from "./agent/index.js";
3
- export { defineAction, AgentActionStopError, isAgentActionStopError, type ActionDefinition, type ActionHttpConfig, type AgentActionStopOptions, type PublicAgentActionConfig, type ActionDeepLink, type ActionLinkBuilder, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, type ActionMcpAppConfig, type ActionMcpAppCsp, type ActionMcpAppCspBuilder, type ActionMcpAppHtmlBuilder, type ActionMcpAppPermissions, type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js";
3
+ export { defineAction, ActionContractError, isActionContractError, type ActionContractErrorOptions, AgentActionStopError, isAgentActionStopError, type ActionDefinition, type ActionHttpConfig, type AgentActionStopOptions, type PublicAgentActionConfig, type ActionDeepLink, type ActionLinkBuilder, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, type ActionMcpAppConfig, type ActionMcpAppCsp, type ActionMcpAppCspBuilder, type ActionMcpAppHtmlBuilder, type ActionMcpAppPermissions, type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js";
4
4
  export * from "./user-profile/index.js";
5
5
  export { embedApp, MCP_APP_REQUEST_ORIGIN_CSP_SOURCE, type EmbedAppOptions, } from "./mcp/embed-app.js";
6
6
  export { embedRoute, type EmbedRouteContext, type EmbedRouteOptions, type EmbedRoutePathBuilder, type EmbedRouteResult, } from "./mcp/embed-route.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export * from "./config.js";
5
5
  export { createProductionAgentHandler, DEFAULT_MODEL,
6
6
  // In-loop processors (observers/guardrails for runAgentLoop)
7
7
  TripWire, } from "./agent/index.js";
8
- export { defineAction, AgentActionStopError, isAgentActionStopError, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, } from "./action.js";
8
+ export { defineAction, ActionContractError, isActionContractError, AgentActionStopError, isAgentActionStopError, MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI_META_KEY, } from "./action.js";
9
9
  export * from "./user-profile/index.js";
10
10
  export { embedApp, MCP_APP_REQUEST_ORIGIN_CSP_SOURCE, } from "./mcp/embed-app.js";
11
11
  export { embedRoute, } from "./mcp/embed-route.js";
@@ -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
  }