@agent-native/core 0.18.1 → 0.19.1

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 (168) hide show
  1. package/README.md +1 -11
  2. package/dist/a2a/caller-auth.d.ts +1 -0
  3. package/dist/a2a/caller-auth.d.ts.map +1 -1
  4. package/dist/a2a/caller-auth.js +1 -1
  5. package/dist/a2a/caller-auth.js.map +1 -1
  6. package/dist/a2a/client.d.ts +7 -0
  7. package/dist/a2a/client.d.ts.map +1 -1
  8. package/dist/a2a/client.js +3 -0
  9. package/dist/a2a/client.js.map +1 -1
  10. package/dist/agent/production-agent.d.ts +1 -1
  11. package/dist/agent/production-agent.d.ts.map +1 -1
  12. package/dist/agent/production-agent.js +34 -2
  13. package/dist/agent/production-agent.js.map +1 -1
  14. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  15. package/dist/cli/code-agent-executor.js +47 -256
  16. package/dist/cli/code-agent-executor.js.map +1 -1
  17. package/dist/cli/connect.d.ts +94 -0
  18. package/dist/cli/connect.d.ts.map +1 -0
  19. package/dist/cli/connect.js +443 -0
  20. package/dist/cli/connect.js.map +1 -0
  21. package/dist/cli/index.js +16 -0
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/mcp-config-writers.d.ts +71 -0
  24. package/dist/cli/mcp-config-writers.d.ts.map +1 -0
  25. package/dist/cli/mcp-config-writers.js +210 -0
  26. package/dist/cli/mcp-config-writers.js.map +1 -0
  27. package/dist/client/AgentPanel.d.ts +3 -1
  28. package/dist/client/AgentPanel.d.ts.map +1 -1
  29. package/dist/client/AgentPanel.js +4 -4
  30. package/dist/client/AgentPanel.js.map +1 -1
  31. package/dist/client/AssistantChat.d.ts +3 -0
  32. package/dist/client/AssistantChat.d.ts.map +1 -1
  33. package/dist/client/AssistantChat.js +22 -66
  34. package/dist/client/AssistantChat.js.map +1 -1
  35. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  36. package/dist/client/MultiTabAssistantChat.js +4 -1
  37. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  38. package/dist/client/composer/PromptComposer.d.ts +6 -1
  39. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  40. package/dist/client/composer/PromptComposer.js +5 -4
  41. package/dist/client/composer/PromptComposer.js.map +1 -1
  42. package/dist/client/composer/TiptapComposer.d.ts +6 -1
  43. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  44. package/dist/client/composer/TiptapComposer.js +20 -10
  45. package/dist/client/composer/TiptapComposer.js.map +1 -1
  46. package/dist/client/conversation/AgentConversation.d.ts +18 -0
  47. package/dist/client/conversation/AgentConversation.d.ts.map +1 -0
  48. package/dist/client/conversation/AgentConversation.js +94 -0
  49. package/dist/client/conversation/AgentConversation.js.map +1 -0
  50. package/dist/client/conversation/AgentConversation.spec.d.ts +2 -0
  51. package/dist/client/conversation/AgentConversation.spec.d.ts.map +1 -0
  52. package/dist/client/conversation/AgentConversation.spec.js +69 -0
  53. package/dist/client/conversation/AgentConversation.spec.js.map +1 -0
  54. package/dist/client/conversation/index.d.ts +4 -0
  55. package/dist/client/conversation/index.d.ts.map +1 -0
  56. package/dist/client/conversation/index.js +3 -0
  57. package/dist/client/conversation/index.js.map +1 -0
  58. package/dist/client/conversation/types.d.ts +54 -0
  59. package/dist/client/conversation/types.d.ts.map +1 -0
  60. package/dist/client/conversation/types.js +2 -0
  61. package/dist/client/conversation/types.js.map +1 -0
  62. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts +15 -0
  63. package/dist/client/conversation/use-near-bottom-autoscroll.d.ts.map +1 -0
  64. package/dist/client/conversation/use-near-bottom-autoscroll.js +66 -0
  65. package/dist/client/conversation/use-near-bottom-autoscroll.js.map +1 -0
  66. package/dist/client/dynamic-suggestions.d.ts +43 -0
  67. package/dist/client/dynamic-suggestions.d.ts.map +1 -0
  68. package/dist/client/dynamic-suggestions.js +344 -0
  69. package/dist/client/dynamic-suggestions.js.map +1 -0
  70. package/dist/client/index.d.ts +2 -0
  71. package/dist/client/index.d.ts.map +1 -1
  72. package/dist/client/index.js +2 -0
  73. package/dist/client/index.js.map +1 -1
  74. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  75. package/dist/client/resources/ResourceTree.js +2 -2
  76. package/dist/client/resources/ResourceTree.js.map +1 -1
  77. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  78. package/dist/client/resources/ResourcesPanel.js +4 -28
  79. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  80. package/dist/client/settings/SettingsPanel.js +2 -2
  81. package/dist/client/settings/SettingsPanel.js.map +1 -1
  82. package/dist/code-agents/index.d.ts +1 -0
  83. package/dist/code-agents/index.d.ts.map +1 -1
  84. package/dist/code-agents/index.js +1 -0
  85. package/dist/code-agents/index.js.map +1 -1
  86. package/dist/code-agents/transcript-normalizer.d.ts +50 -0
  87. package/dist/code-agents/transcript-normalizer.d.ts.map +1 -0
  88. package/dist/code-agents/transcript-normalizer.js +356 -0
  89. package/dist/code-agents/transcript-normalizer.js.map +1 -0
  90. package/dist/coding-tools/index.d.ts +31 -0
  91. package/dist/coding-tools/index.d.ts.map +1 -0
  92. package/dist/coding-tools/index.js +411 -0
  93. package/dist/coding-tools/index.js.map +1 -0
  94. package/dist/extensions/schema.d.ts +1 -1
  95. package/dist/mcp/build-server.d.ts.map +1 -1
  96. package/dist/mcp/build-server.js +30 -0
  97. package/dist/mcp/build-server.js.map +1 -1
  98. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  99. package/dist/mcp/builtin-tools.js +85 -26
  100. package/dist/mcp/builtin-tools.js.map +1 -1
  101. package/dist/mcp/connect-route.d.ts +43 -0
  102. package/dist/mcp/connect-route.d.ts.map +1 -0
  103. package/dist/mcp/connect-route.js +744 -0
  104. package/dist/mcp/connect-route.js.map +1 -0
  105. package/dist/mcp/connect-store.d.ts +132 -0
  106. package/dist/mcp/connect-store.d.ts.map +1 -0
  107. package/dist/mcp/connect-store.js +434 -0
  108. package/dist/mcp/connect-store.js.map +1 -0
  109. package/dist/mcp/org-directory.d.ts +83 -0
  110. package/dist/mcp/org-directory.d.ts.map +1 -0
  111. package/dist/mcp/org-directory.js +201 -0
  112. package/dist/mcp/org-directory.js.map +1 -0
  113. package/dist/mcp/server.d.ts +38 -1
  114. package/dist/mcp/server.d.ts.map +1 -1
  115. package/dist/mcp/server.js +208 -77
  116. package/dist/mcp/server.js.map +1 -1
  117. package/dist/scripts/dev/index.d.ts +6 -4
  118. package/dist/scripts/dev/index.d.ts.map +1 -1
  119. package/dist/scripts/dev/index.js +28 -13
  120. package/dist/scripts/dev/index.js.map +1 -1
  121. package/dist/server/agent-chat-plugin.d.ts +6 -6
  122. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  123. package/dist/server/agent-chat-plugin.js +32 -32
  124. package/dist/server/agent-chat-plugin.js.map +1 -1
  125. package/dist/server/agent-teams.js +2 -2
  126. package/dist/server/agent-teams.js.map +1 -1
  127. package/dist/server/agents-bundle.d.ts +3 -3
  128. package/dist/server/agents-bundle.js +5 -5
  129. package/dist/server/agents-bundle.js.map +1 -1
  130. package/dist/server/auth.d.ts +17 -0
  131. package/dist/server/auth.d.ts.map +1 -1
  132. package/dist/server/auth.js +149 -33
  133. package/dist/server/auth.js.map +1 -1
  134. package/dist/server/better-auth-instance.d.ts +43 -0
  135. package/dist/server/better-auth-instance.d.ts.map +1 -1
  136. package/dist/server/better-auth-instance.js +25 -0
  137. package/dist/server/better-auth-instance.js.map +1 -1
  138. package/dist/server/core-routes-plugin.d.ts +12 -0
  139. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  140. package/dist/server/core-routes-plugin.js +42 -0
  141. package/dist/server/core-routes-plugin.js.map +1 -1
  142. package/dist/server/identity-sso-store.d.ts +86 -0
  143. package/dist/server/identity-sso-store.d.ts.map +1 -0
  144. package/dist/server/identity-sso-store.js +243 -0
  145. package/dist/server/identity-sso-store.js.map +1 -0
  146. package/dist/server/identity-sso.d.ts +78 -0
  147. package/dist/server/identity-sso.d.ts.map +1 -0
  148. package/dist/server/identity-sso.js +425 -0
  149. package/dist/server/identity-sso.js.map +1 -0
  150. package/dist/server/index.d.ts +1 -0
  151. package/dist/server/index.d.ts.map +1 -1
  152. package/dist/server/index.js +1 -0
  153. package/dist/server/index.js.map +1 -1
  154. package/dist/server/onboarding-html.d.ts.map +1 -1
  155. package/dist/server/onboarding-html.js +2 -1
  156. package/dist/server/onboarding-html.js.map +1 -1
  157. package/dist/server/sentry.d.ts.map +1 -1
  158. package/dist/server/sentry.js +17 -2
  159. package/dist/server/sentry.js.map +1 -1
  160. package/dist/sharing/schema.d.ts +1 -1
  161. package/docs/content/client.md +15 -0
  162. package/docs/content/code-agents-ui.md +25 -4
  163. package/docs/content/cross-app-sso.md +118 -0
  164. package/docs/content/drop-in-agent.md +3 -1
  165. package/docs/content/external-agents.md +130 -51
  166. package/docs/content/frames.md +1 -1
  167. package/docs/content/migration-workbench.md +6 -1
  168. package/package.json +2 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Org-directory discovery for the generic cross-app MCP verbs
3
+ * (`list_apps` / `ask_app` in `builtin-tools.ts`).
4
+ *
5
+ * Phase 3b of cross-app auto-wiring. Today the cross-app verbs resolve sibling
6
+ * apps from *local workspace* info only (`workspace-resolve.ts`), so the mail
7
+ * agent can only reach the calendar agent in a local dev workspace. When the
8
+ * deployment runs against an org directory (Dispatch is also the identity hub
9
+ * for the org), this module discovers the org's *deployed* sibling apps so the
10
+ * same verbs work cross-app in production with ZERO manual setup.
11
+ *
12
+ * ## The directory request
13
+ *
14
+ * GET <directoryOrigin>/_agent-native/org/apps
15
+ * Auth Authorization: Bearer <org A2A token> (same signed token A2A peers
16
+ * already mint — reuses `resolveA2ACallerAuth()`; the org A2A secret /
17
+ * global `A2A_SECRET` is loaded exactly how outgoing A2A calls load it)
18
+ * ⇒ { org, apps: [ { id, name, url, a2aUrl, capabilities? } ] }
19
+ * (allow-listed first-party apps only, prod URLs — enforced by the
20
+ * authority side, Phase 3a, on Dispatch)
21
+ *
22
+ * ## Resolution + safety model
23
+ *
24
+ * - The directory origin is read from env: `AGENT_NATIVE_ORG_DIRECTORY_URL`
25
+ * (dedicated) or `AGENT_NATIVE_IDENTITY_HUB_URL` (Dispatch is also the
26
+ * identity hub). When *neither* is set the feature is simply inactive —
27
+ * `fetchOrgApps()` returns `[]` and nothing changes anywhere (asserted by
28
+ * a test). This makes the whole feature opt-in and back-compat.
29
+ * - On ANY error (no env, unreachable, 401, non-2xx, bad JSON, no signed
30
+ * token) `fetchOrgApps()` returns `[]` and NEVER throws — the cross-app
31
+ * verbs degrade silently to their exact current local-only behavior.
32
+ * - A short in-memory TTL cache (default 60s) keyed by directory origin and
33
+ * caller identity/org scope so sibling app lists never cross tenants.
34
+ * Empty authenticated results are cached too (with a shorter TTL) so a
35
+ * transient failure doesn't hammer the directory on every call.
36
+ * - No secrets are ever logged.
37
+ *
38
+ * Bundled alongside `mountMCP` (no Node-only top-level imports). The A2A
39
+ * caller-auth + a2a client are dynamically imported inside `fetchOrgApps()`.
40
+ */
41
+ /** Default cache TTL for a successful directory fetch. */
42
+ const SUCCESS_TTL_MS = 60_000;
43
+ /** Shorter TTL for an empty/failed fetch so transient errors recover fast. */
44
+ const EMPTY_TTL_MS = 10_000;
45
+ /** In-memory cache keyed by resolved directory origin (+ identity scope). */
46
+ const cache = new Map();
47
+ /**
48
+ * Resolve the org-directory origin from env. Returns `null` when neither env
49
+ * var is set — the caller treats `null` as "feature inactive".
50
+ *
51
+ * `env` is injectable for tests; defaults to `process.env`.
52
+ */
53
+ export function resolveOrgDirectoryOrigin(env = process.env) {
54
+ const raw = env.AGENT_NATIVE_ORG_DIRECTORY_URL || env.AGENT_NATIVE_IDENTITY_HUB_URL;
55
+ if (!raw || typeof raw !== "string")
56
+ return null;
57
+ const trimmed = raw.trim().replace(/\/+$/, "");
58
+ if (!trimmed)
59
+ return null;
60
+ try {
61
+ // Validate it's an absolute http(s) URL; reject anything else.
62
+ const u = new URL(trimmed);
63
+ if (u.protocol !== "http:" && u.protocol !== "https:")
64
+ return null;
65
+ return trimmed;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function normalizeApp(raw) {
72
+ if (!raw || typeof raw !== "object")
73
+ return null;
74
+ const r = raw;
75
+ const id = typeof r.id === "string" ? r.id.trim().toLowerCase() : "";
76
+ const url = typeof r.url === "string" ? r.url.trim() : "";
77
+ if (!id || !url)
78
+ return null;
79
+ // Only accept absolute http(s) URLs from the directory.
80
+ try {
81
+ const u = new URL(url);
82
+ if (u.protocol !== "http:" && u.protocol !== "https:")
83
+ return null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ const name = typeof r.name === "string" && r.name.trim() ? r.name.trim() : id;
89
+ const a2aUrl = typeof r.a2aUrl === "string" && r.a2aUrl.trim() ? r.a2aUrl.trim() : url;
90
+ const capabilities = Array.isArray(r.capabilities)
91
+ ? r.capabilities.filter((c) => typeof c === "string")
92
+ : undefined;
93
+ return {
94
+ id,
95
+ name,
96
+ url: url.replace(/\/+$/, ""),
97
+ a2aUrl: a2aUrl.replace(/\/+$/, ""),
98
+ ...(capabilities && capabilities.length ? { capabilities } : {}),
99
+ };
100
+ }
101
+ /** Compare two origins by host (ignores trailing slash / protocol noise). */
102
+ function sameOrigin(a, b) {
103
+ try {
104
+ const ua = new URL(a);
105
+ const ub = new URL(b);
106
+ return ua.host === ub.host && ua.protocol === ub.protocol;
107
+ }
108
+ catch {
109
+ return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
110
+ }
111
+ }
112
+ function scopedCacheKey(origin, auth) {
113
+ return [
114
+ origin,
115
+ `user:${auth.userEmail ?? ""}`,
116
+ `org:${auth.orgId ?? auth.orgDomain ?? ""}`,
117
+ ].join("|");
118
+ }
119
+ /**
120
+ * Fetch the org's first-party sibling apps from the org directory.
121
+ *
122
+ * - Returns `[]` (never throws) on ANY failure or when the directory env is
123
+ * unset — the cross-app verbs then keep their exact local-only behavior.
124
+ * - Short in-memory TTL cache so it isn't fetched on every tool call.
125
+ * - Strips the current app from the result (compared by id and by origin) so
126
+ * `list_apps` / `ask_app` never offer to route to themselves.
127
+ *
128
+ * @param opts.selfId Current app id (so it's stripped from the result).
129
+ * @param opts.selfOrigin Current app origin (so it's stripped by origin too).
130
+ * @param opts.env Injectable env (tests). Defaults to `process.env`.
131
+ */
132
+ export async function fetchOrgApps(opts) {
133
+ const env = opts?.env ?? process.env;
134
+ const origin = resolveOrgDirectoryOrigin(env);
135
+ // Feature inactive: no directory configured ⇒ behave exactly as before.
136
+ if (!origin)
137
+ return [];
138
+ const selfId = (opts?.selfId ?? "").trim().toLowerCase();
139
+ const selfOrigin = (opts?.selfOrigin ?? "").trim();
140
+ const stripSelf = (apps) => apps.filter((a) => {
141
+ if (selfId && a.id === selfId)
142
+ return false;
143
+ if (selfOrigin && sameOrigin(a.url, selfOrigin))
144
+ return false;
145
+ return true;
146
+ });
147
+ let cacheKey = null;
148
+ let apps = [];
149
+ let ttl = EMPTY_TTL_MS;
150
+ try {
151
+ // Reuse the existing A2A caller-auth: it reads userEmail + orgId from the
152
+ // request context, loads the org A2A secret via getOrgA2ASecret (falling
153
+ // back to the global A2A_SECRET env), and signs the same bearer JWT A2A
154
+ // peers already use. No new secret loading is invented here.
155
+ const { resolveA2ACallerAuth } = await import("../a2a/caller-auth.js");
156
+ const auth = await resolveA2ACallerAuth();
157
+ if (!auth.apiKey) {
158
+ // No signed token available (no A2A secret / no caller identity) — the
159
+ // directory requires the org bearer, so degrade silently to local-only.
160
+ return [];
161
+ }
162
+ const now = Date.now();
163
+ cacheKey = scopedCacheKey(origin, auth);
164
+ const cached = cache.get(cacheKey);
165
+ if (cached && cached.expiresAt > now) {
166
+ return stripSelf(cached.apps);
167
+ }
168
+ const res = await fetch(`${origin}/_agent-native/org/apps`, {
169
+ method: "GET",
170
+ headers: {
171
+ Authorization: `Bearer ${auth.apiKey}`,
172
+ Accept: "application/json",
173
+ },
174
+ signal: AbortSignal.timeout(4000),
175
+ });
176
+ if (res.ok) {
177
+ const json = (await res.json());
178
+ const list = Array.isArray(json?.apps) ? json.apps : [];
179
+ apps = list.map(normalizeApp).filter((a) => a !== null);
180
+ ttl = SUCCESS_TTL_MS;
181
+ }
182
+ // Non-2xx ⇒ leave apps=[] with the short EMPTY_TTL (silent degrade).
183
+ }
184
+ catch {
185
+ // Unreachable / parse error / abort ⇒ silent degrade to local-only.
186
+ apps = [];
187
+ ttl = EMPTY_TTL_MS;
188
+ }
189
+ if (cacheKey) {
190
+ cache.set(cacheKey, {
191
+ apps,
192
+ expiresAt: Date.now() + ttl,
193
+ });
194
+ }
195
+ return stripSelf(apps);
196
+ }
197
+ /** Test-only: clear the in-memory cache between cases. */
198
+ export function _resetOrgDirectoryCache() {
199
+ cache.clear();
200
+ }
201
+ //# sourceMappingURL=org-directory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"org-directory.js","sourceRoot":"","sources":["../../src/mcp/org-directory.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Org-directory discovery for the generic cross-app MCP verbs\n * (`list_apps` / `ask_app` in `builtin-tools.ts`).\n *\n * Phase 3b of cross-app auto-wiring. Today the cross-app verbs resolve sibling\n * apps from *local workspace* info only (`workspace-resolve.ts`), so the mail\n * agent can only reach the calendar agent in a local dev workspace. When the\n * deployment runs against an org directory (Dispatch is also the identity hub\n * for the org), this module discovers the org's *deployed* sibling apps so the\n * same verbs work cross-app in production with ZERO manual setup.\n *\n * ## The directory request\n *\n * GET <directoryOrigin>/_agent-native/org/apps\n * Auth Authorization: Bearer <org A2A token> (same signed token A2A peers\n * already mint — reuses `resolveA2ACallerAuth()`; the org A2A secret /\n * global `A2A_SECRET` is loaded exactly how outgoing A2A calls load it)\n * ⇒ { org, apps: [ { id, name, url, a2aUrl, capabilities? } ] }\n * (allow-listed first-party apps only, prod URLs — enforced by the\n * authority side, Phase 3a, on Dispatch)\n *\n * ## Resolution + safety model\n *\n * - The directory origin is read from env: `AGENT_NATIVE_ORG_DIRECTORY_URL`\n * (dedicated) or `AGENT_NATIVE_IDENTITY_HUB_URL` (Dispatch is also the\n * identity hub). When *neither* is set the feature is simply inactive —\n * `fetchOrgApps()` returns `[]` and nothing changes anywhere (asserted by\n * a test). This makes the whole feature opt-in and back-compat.\n * - On ANY error (no env, unreachable, 401, non-2xx, bad JSON, no signed\n * token) `fetchOrgApps()` returns `[]` and NEVER throws — the cross-app\n * verbs degrade silently to their exact current local-only behavior.\n * - A short in-memory TTL cache (default 60s) keyed by directory origin and\n * caller identity/org scope so sibling app lists never cross tenants.\n * Empty authenticated results are cached too (with a shorter TTL) so a\n * transient failure doesn't hammer the directory on every call.\n * - No secrets are ever logged.\n *\n * Bundled alongside `mountMCP` (no Node-only top-level imports). The A2A\n * caller-auth + a2a client are dynamically imported inside `fetchOrgApps()`.\n */\n\nexport interface OrgApp {\n /** Canonical app id, e.g. `calendar`. */\n id: string;\n /** Human-readable name, e.g. `Calendar`. */\n name: string;\n /** Deployed app origin/URL, e.g. `https://calendar.acme.com`. */\n url: string;\n /**\n * A2A endpoint to route `ask_app` to. The authority side returns this; we\n * fall back to the app `url` (the A2A client appends `/_agent-native/a2a`).\n */\n a2aUrl: string;\n /** Optional capability hints the authority side may include. */\n capabilities?: string[];\n}\n\n/** Default cache TTL for a successful directory fetch. */\nconst SUCCESS_TTL_MS = 60_000;\n/** Shorter TTL for an empty/failed fetch so transient errors recover fast. */\nconst EMPTY_TTL_MS = 10_000;\n\ninterface CacheEntry {\n apps: OrgApp[];\n expiresAt: number;\n}\n\n/** In-memory cache keyed by resolved directory origin (+ identity scope). */\nconst cache = new Map<string, CacheEntry>();\n\n/**\n * Resolve the org-directory origin from env. Returns `null` when neither env\n * var is set — the caller treats `null` as \"feature inactive\".\n *\n * `env` is injectable for tests; defaults to `process.env`.\n */\nexport function resolveOrgDirectoryOrigin(\n env: NodeJS.ProcessEnv = process.env,\n): string | null {\n const raw =\n env.AGENT_NATIVE_ORG_DIRECTORY_URL || env.AGENT_NATIVE_IDENTITY_HUB_URL;\n if (!raw || typeof raw !== \"string\") return null;\n const trimmed = raw.trim().replace(/\\/+$/, \"\");\n if (!trimmed) return null;\n try {\n // Validate it's an absolute http(s) URL; reject anything else.\n const u = new URL(trimmed);\n if (u.protocol !== \"http:\" && u.protocol !== \"https:\") return null;\n return trimmed;\n } catch {\n return null;\n }\n}\n\nfunction normalizeApp(raw: unknown): OrgApp | null {\n if (!raw || typeof raw !== \"object\") return null;\n const r = raw as Record<string, unknown>;\n const id = typeof r.id === \"string\" ? r.id.trim().toLowerCase() : \"\";\n const url = typeof r.url === \"string\" ? r.url.trim() : \"\";\n if (!id || !url) return null;\n // Only accept absolute http(s) URLs from the directory.\n try {\n const u = new URL(url);\n if (u.protocol !== \"http:\" && u.protocol !== \"https:\") return null;\n } catch {\n return null;\n }\n const name = typeof r.name === \"string\" && r.name.trim() ? r.name.trim() : id;\n const a2aUrl =\n typeof r.a2aUrl === \"string\" && r.a2aUrl.trim() ? r.a2aUrl.trim() : url;\n const capabilities = Array.isArray(r.capabilities)\n ? r.capabilities.filter((c): c is string => typeof c === \"string\")\n : undefined;\n return {\n id,\n name,\n url: url.replace(/\\/+$/, \"\"),\n a2aUrl: a2aUrl.replace(/\\/+$/, \"\"),\n ...(capabilities && capabilities.length ? { capabilities } : {}),\n };\n}\n\n/** Compare two origins by host (ignores trailing slash / protocol noise). */\nfunction sameOrigin(a: string, b: string): boolean {\n try {\n const ua = new URL(a);\n const ub = new URL(b);\n return ua.host === ub.host && ua.protocol === ub.protocol;\n } catch {\n return a.replace(/\\/+$/, \"\") === b.replace(/\\/+$/, \"\");\n }\n}\n\nfunction scopedCacheKey(\n origin: string,\n auth: {\n userEmail?: string;\n orgId?: string;\n orgDomain?: string;\n },\n): string {\n return [\n origin,\n `user:${auth.userEmail ?? \"\"}`,\n `org:${auth.orgId ?? auth.orgDomain ?? \"\"}`,\n ].join(\"|\");\n}\n\n/**\n * Fetch the org's first-party sibling apps from the org directory.\n *\n * - Returns `[]` (never throws) on ANY failure or when the directory env is\n * unset — the cross-app verbs then keep their exact local-only behavior.\n * - Short in-memory TTL cache so it isn't fetched on every tool call.\n * - Strips the current app from the result (compared by id and by origin) so\n * `list_apps` / `ask_app` never offer to route to themselves.\n *\n * @param opts.selfId Current app id (so it's stripped from the result).\n * @param opts.selfOrigin Current app origin (so it's stripped by origin too).\n * @param opts.env Injectable env (tests). Defaults to `process.env`.\n */\nexport async function fetchOrgApps(opts?: {\n selfId?: string;\n selfOrigin?: string;\n env?: NodeJS.ProcessEnv;\n}): Promise<OrgApp[]> {\n const env = opts?.env ?? process.env;\n const origin = resolveOrgDirectoryOrigin(env);\n // Feature inactive: no directory configured ⇒ behave exactly as before.\n if (!origin) return [];\n\n const selfId = (opts?.selfId ?? \"\").trim().toLowerCase();\n const selfOrigin = (opts?.selfOrigin ?? \"\").trim();\n\n const stripSelf = (apps: OrgApp[]): OrgApp[] =>\n apps.filter((a) => {\n if (selfId && a.id === selfId) return false;\n if (selfOrigin && sameOrigin(a.url, selfOrigin)) return false;\n return true;\n });\n\n let cacheKey: string | null = null;\n let apps: OrgApp[] = [];\n let ttl = EMPTY_TTL_MS;\n try {\n // Reuse the existing A2A caller-auth: it reads userEmail + orgId from the\n // request context, loads the org A2A secret via getOrgA2ASecret (falling\n // back to the global A2A_SECRET env), and signs the same bearer JWT A2A\n // peers already use. No new secret loading is invented here.\n const { resolveA2ACallerAuth } = await import(\"../a2a/caller-auth.js\");\n const auth = await resolveA2ACallerAuth();\n if (!auth.apiKey) {\n // No signed token available (no A2A secret / no caller identity) — the\n // directory requires the org bearer, so degrade silently to local-only.\n return [];\n }\n\n const now = Date.now();\n cacheKey = scopedCacheKey(origin, auth);\n const cached = cache.get(cacheKey);\n if (cached && cached.expiresAt > now) {\n return stripSelf(cached.apps);\n }\n\n const res = await fetch(`${origin}/_agent-native/org/apps`, {\n method: \"GET\",\n headers: {\n Authorization: `Bearer ${auth.apiKey}`,\n Accept: \"application/json\",\n },\n signal: AbortSignal.timeout(4000),\n });\n if (res.ok) {\n const json = (await res.json()) as { apps?: unknown };\n const list = Array.isArray(json?.apps) ? json.apps : [];\n apps = list.map(normalizeApp).filter((a): a is OrgApp => a !== null);\n ttl = SUCCESS_TTL_MS;\n }\n // Non-2xx ⇒ leave apps=[] with the short EMPTY_TTL (silent degrade).\n } catch {\n // Unreachable / parse error / abort ⇒ silent degrade to local-only.\n apps = [];\n ttl = EMPTY_TTL_MS;\n }\n\n if (cacheKey) {\n cache.set(cacheKey, {\n apps,\n expiresAt: Date.now() + ttl,\n });\n }\n return stripSelf(apps);\n}\n\n/** Test-only: clear the in-memory cache between cases. */\nexport function _resetOrgDirectoryCache(): void {\n cache.clear();\n}\n"]}
@@ -1,13 +1,50 @@
1
+ import type { H3Event } from "h3";
1
2
  import { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFromDomain, buildLinkArtifacts, type MCPConfig, type MCPCallerIdentity, type MCPRequestMeta } from "./build-server.js";
2
3
  export { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFromDomain, buildLinkArtifacts, };
3
4
  export type { MCPConfig, MCPCallerIdentity, MCPRequestMeta };
5
+ /**
6
+ * Handle a single `{routePrefix}/mcp` request on either runtime.
7
+ *
8
+ * - **Node fast-path** (real Node HTTP server): unchanged — delegate to the
9
+ * SDK's `StreamableHTTPServerTransport.handleRequest(nodeReq, nodeRes,
10
+ * body)`, which writes directly to the Node response (full protocol incl.
11
+ * SSE).
12
+ * - **Web-standard fallback** (Nitro 3 / Netlify web runtime, Cloudflare,
13
+ * Deno, Bun — where there is no Node req/res): build the SAME MCP `Server`
14
+ * from the SAME config + identity, drive it through the SDK's
15
+ * `WebStandardStreamableHTTPServerTransport` (which the Node transport is
16
+ * itself just a thin wrapper around), and return the resulting Web
17
+ * `Response` as a normal h3 return value.
18
+ *
19
+ * Auth, the `runWithRequestContext` identity wrap, the deep-link `_meta` /
20
+ * markdown append, `requestMeta` origin/target derivation and the stateless
21
+ * semantics are IDENTICAL on both paths because both build the same server
22
+ * via `createMCPServerForRequest` and both transports funnel into the same
23
+ * `WebStandardStreamableHTTPServerTransport.handleRequest(webRequest, {
24
+ * parsedBody })` with the same options.
25
+ *
26
+ * Returns:
27
+ * - `undefined` when the request targets a sub-route (so management/status
28
+ * routes mounted under `/_agent-native/mcp/*` handle it themselves) — the
29
+ * h3 mount falls through to the next handler.
30
+ * - a Web `Response` (web fallback) or a string/object (Node path /
31
+ * auth-error path) otherwise. The Node path also sets `_handled` so h3
32
+ * doesn't double-write.
33
+ */
34
+ export declare function handleMcpRequest(event: H3Event, config: MCPConfig): Promise<Response | string | {
35
+ error: string;
36
+ } | undefined>;
4
37
  /**
5
38
  * Mount an MCP remote server on an H3/Nitro app.
6
39
  *
7
40
  * Endpoint: `{routePrefix}/mcp` (default `/_agent-native/mcp`)
8
41
  *
9
42
  * Uses stateless Streamable HTTP transport — no in-memory sessions,
10
- * compatible with serverless deployments.
43
+ * compatible with serverless deployments. Runtime-agnostic: a real Node
44
+ * server uses the SDK's Node transport; the web-standard runtime (Nitro 3 /
45
+ * Netlify web runtime, Cloudflare, Deno, Bun) uses the SDK's web-standard
46
+ * transport. Both build the same server and produce identical JSON-RPC
47
+ * output.
11
48
  *
12
49
  * Auth: Bearer token matching ACCESS_TOKEN/ACCESS_TOKENS or JWT via A2A_SECRET.
13
50
  * No auth required when neither is configured (dev mode).
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,yBAAyB,EACzB,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,kBAAkB,EAClB,KAAK,SAAS,EACd,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EACL,yBAAyB,EACzB,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,kBAAkB,GACnB,CAAC;AACF,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC;AAM7D;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,GAAG,EACb,MAAM,EAAE,SAAS,EACjB,WAAW,SAAmB,GAC7B,IAAI,CAwHN"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,IAAI,CAAC;AASlC,OAAO,EACL,yBAAyB,EACzB,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,kBAAkB,EAClB,KAAK,SAAS,EACd,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EACL,yBAAyB,EACzB,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,kBAAkB,GACnB,CAAC;AACF,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC;AA2G7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,QAAQ,GAAG,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAgH5D;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,GAAG,EACb,MAAM,EAAE,SAAS,EACjB,WAAW,SAAmB,GAC7B,IAAI,CAYN"}
@@ -7,98 +7,182 @@ import { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFro
7
7
  // against `./server.js` exactly as before this refactor.
8
8
  export { createMCPServerForRequest, verifyAuth, getAccessTokens, resolveOrgIdFromDomain, buildLinkArtifacts, };
9
9
  // ---------------------------------------------------------------------------
10
- // mountMCPregister MCP Streamable HTTP endpoint on H3/Nitro
10
+ // Runtime detection Node fast-path vs. web-standard fallback
11
11
  // ---------------------------------------------------------------------------
12
12
  /**
13
- * Mount an MCP remote server on an H3/Nitro app.
13
+ * Resolve the underlying Node `http` req/res pair if (and only if) we're
14
+ * running on a real Node HTTP server (local dev, `node` Nitro preset). On the
15
+ * web-standard runtime (Nitro 3 / Netlify web runtime, Cloudflare, Deno, Bun)
16
+ * BOTH of these are undefined — that's the signal to take the web fallback
17
+ * instead of returning 501.
18
+ */
19
+ function getNodeReqRes(event) {
20
+ const e = event;
21
+ const nodeReq = e.node?.req ?? e.req?.runtime?.node?.req;
22
+ const nodeRes = e.node?.res ?? e.req?.runtime?.node?.res;
23
+ return { nodeReq, nodeRes };
24
+ }
25
+ /**
26
+ * Derive the request origin + the markdown deep-link target from the inbound
27
+ * headers. Identical logic for both the Node and web paths so the absolute
28
+ * deep-link URLs in tool results are computed the same way regardless of
29
+ * runtime.
30
+ */
31
+ function deriveRequestMeta(event) {
32
+ const forwardedProto = getRequestHeader(event, "x-forwarded-proto");
33
+ const host = getRequestHeader(event, "host");
34
+ const proto = forwardedProto?.split(",")[0]?.trim() ||
35
+ (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https");
36
+ const origin = host ? `${proto}://${host}` : undefined;
37
+ const targetHeader = getRequestHeader(event, "x-agent-native-open-target")?.toLowerCase();
38
+ const target = targetHeader === "desktop" ||
39
+ targetHeader === "terminal" ||
40
+ targetHeader === "browser"
41
+ ? targetHeader
42
+ : undefined;
43
+ return { origin, target };
44
+ }
45
+ /**
46
+ * Reconstruct a Web Standard `Request` for the web-standard MCP transport.
14
47
  *
15
- * Endpoint: `{routePrefix}/mcp` (default `/_agent-native/mcp`)
48
+ * On the web runtime h3 v2 exposes the real web `Request` as `event.req`; we
49
+ * prefer it (its `method` / `headers` are exactly what the client sent). But
50
+ * the framework middleware rewrites `event.req.url` when it strips a mount
51
+ * prefix, and the transport reads `req.method` + `req.headers` (never the
52
+ * body — we pass that via `parsedBody`), so we always synthesize a clean
53
+ * `Request` with the verified method + a fresh `Headers` copy. The URL is
54
+ * cosmetic for the SDK (it only does `new URL(req.url)` for `requestInfo`),
55
+ * so a best-effort absolute URL derived from the inbound host is sufficient
56
+ * and never throws.
57
+ */
58
+ function buildWebRequest(event, method) {
59
+ const src = event.req;
60
+ const headers = new Headers();
61
+ if (src?.headers && typeof src.headers.forEach === "function") {
62
+ src.headers.forEach((value, key) => headers.set(key, value));
63
+ }
64
+ else {
65
+ const rawHeaders = event.node?.req?.headers;
66
+ if (rawHeaders) {
67
+ for (const [key, value] of Object.entries(rawHeaders)) {
68
+ if (value == null)
69
+ continue;
70
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
71
+ }
72
+ }
73
+ }
74
+ // The SDK requires Accept + Content-Type to advertise both JSON and SSE on
75
+ // a POST. Real MCP clients (Claude Code, `agent-native connect`) always
76
+ // send these; we never inject/alter them — if they're absent the SDK
77
+ // returns its spec-mandated 406/415, identical to the Node path.
78
+ const host = headers.get("host") || "localhost";
79
+ const forwardedProto = headers.get("x-forwarded-proto");
80
+ const proto = forwardedProto?.split(",")[0]?.trim() ||
81
+ (/^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https");
82
+ let url = `${proto}://${host}/_agent-native/mcp`;
83
+ try {
84
+ if (src?.url)
85
+ url = new URL(src.url).href;
86
+ }
87
+ catch {
88
+ // keep the synthesized URL
89
+ }
90
+ // No body here on purpose: the JSON-RPC payload is forwarded via the
91
+ // transport's `parsedBody` option (the same mechanism the Node transport
92
+ // uses), so the request stream is never read twice.
93
+ return new Request(url, { method, headers });
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // handleMcpRequest — runtime-agnostic MCP request handler
97
+ // ---------------------------------------------------------------------------
98
+ /**
99
+ * Handle a single `{routePrefix}/mcp` request on either runtime.
16
100
  *
17
- * Uses stateless Streamable HTTP transportno in-memory sessions,
18
- * compatible with serverless deployments.
101
+ * - **Node fast-path** (real Node HTTP server): unchanged delegate to the
102
+ * SDK's `StreamableHTTPServerTransport.handleRequest(nodeReq, nodeRes,
103
+ * body)`, which writes directly to the Node response (full protocol incl.
104
+ * SSE).
105
+ * - **Web-standard fallback** (Nitro 3 / Netlify web runtime, Cloudflare,
106
+ * Deno, Bun — where there is no Node req/res): build the SAME MCP `Server`
107
+ * from the SAME config + identity, drive it through the SDK's
108
+ * `WebStandardStreamableHTTPServerTransport` (which the Node transport is
109
+ * itself just a thin wrapper around), and return the resulting Web
110
+ * `Response` as a normal h3 return value.
19
111
  *
20
- * Auth: Bearer token matching ACCESS_TOKEN/ACCESS_TOKENS or JWT via A2A_SECRET.
21
- * No auth required when neither is configured (dev mode).
112
+ * Auth, the `runWithRequestContext` identity wrap, the deep-link `_meta` /
113
+ * markdown append, `requestMeta` origin/target derivation and the stateless
114
+ * semantics are IDENTICAL on both paths because both build the same server
115
+ * via `createMCPServerForRequest` and both transports funnel into the same
116
+ * `WebStandardStreamableHTTPServerTransport.handleRequest(webRequest, {
117
+ * parsedBody })` with the same options.
118
+ *
119
+ * Returns:
120
+ * - `undefined` when the request targets a sub-route (so management/status
121
+ * routes mounted under `/_agent-native/mcp/*` handle it themselves) — the
122
+ * h3 mount falls through to the next handler.
123
+ * - a Web `Response` (web fallback) or a string/object (Node path /
124
+ * auth-error path) otherwise. The Node path also sets `_handled` so h3
125
+ * doesn't double-write.
22
126
  */
23
- export function mountMCP(nitroApp, config, routePrefix = "/_agent-native") {
24
- getH3App(nitroApp).use(`${routePrefix}/mcp`, defineEventHandler(async (event) => {
25
- const pathname = event.url?.pathname || "/";
26
- const subpath = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
27
- if (subpath) {
28
- // Let management/status routes mounted under /_agent-native/mcp/*
29
- // handle their own requests instead of treating them as MCP protocol
30
- // traffic.
31
- return;
32
- }
33
- const method = getMethod(event);
34
- // Auth check — extracts the caller's identity from the JWT (`sub`),
35
- // or, on the static-token / dev-open path, from the forwarded
36
- // `X-Agent-Native-Owner-Email` hint the stdio proxy sends (the
37
- // `agent-native mcp install` flow). Without this the install flow
38
- // would run every tool unscoped (userEmail === undefined).
39
- const authHeader = getRequestHeader(event, "authorization");
40
- const ownerEmailHeader = getRequestHeader(event, "x-agent-native-owner-email");
41
- const authResult = await verifyAuth(authHeader, ownerEmailHeader);
42
- if (!authResult.authed) {
43
- setResponseStatus(event, 401);
44
- return { error: "Unauthorized" };
45
- }
46
- // Stateless mode: only POST is meaningful
47
- if (method === "DELETE") {
48
- setResponseStatus(event, 204);
49
- return "";
50
- }
51
- if (method === "GET") {
52
- // SSE stream endpoint — not used in stateless mode but the SDK
53
- // handles it gracefully. Let it through for protocol compliance.
54
- }
55
- if (method !== "POST" && method !== "GET") {
56
- setResponseStatus(event, 405);
57
- return { error: "Method not allowed" };
58
- }
59
- // Read body for POST (GET has no body)
60
- const body = method === "POST" ? await readBody(event) : undefined;
61
- // Create per-request stateless transport + server
127
+ export async function handleMcpRequest(event, config) {
128
+ const pathname = event.url?.pathname || "/";
129
+ const subpath = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
130
+ if (subpath) {
131
+ // Let management/status routes mounted under /_agent-native/mcp/* handle
132
+ // their own requests instead of treating them as MCP protocol traffic.
133
+ return undefined;
134
+ }
135
+ const method = getMethod(event);
136
+ // Auth check — extracts the caller's identity from the JWT (`sub`), or, on
137
+ // the static-token / dev-open path, from the forwarded
138
+ // `X-Agent-Native-Owner-Email` hint the stdio proxy sends (the
139
+ // `agent-native mcp install` flow). Without this the install flow would run
140
+ // every tool unscoped (userEmail === undefined).
141
+ const authHeader = getRequestHeader(event, "authorization");
142
+ const ownerEmailHeader = getRequestHeader(event, "x-agent-native-owner-email");
143
+ const authResult = await verifyAuth(authHeader, ownerEmailHeader);
144
+ if (!authResult.authed) {
145
+ setResponseStatus(event, 401);
146
+ return { error: "Unauthorized" };
147
+ }
148
+ // Stateless mode: only POST is meaningful
149
+ if (method === "DELETE") {
150
+ setResponseStatus(event, 204);
151
+ return "";
152
+ }
153
+ if (method !== "POST" && method !== "GET") {
154
+ setResponseStatus(event, 405);
155
+ return { error: "Method not allowed" };
156
+ }
157
+ // Read body for POST (GET has no body). Read it via the h3 helper exactly
158
+ // once; both transports accept it as a pre-parsed body so the request
159
+ // stream is never consumed twice.
160
+ const body = method === "POST" ? await readBody(event) : undefined;
161
+ // Per-request stateless transport + server. Both runtimes build the SAME
162
+ // server from the SAME config + verified identity + request meta, so
163
+ // tools/list, tools/call, and the deep-link `_meta` are identical.
164
+ const requestMeta = deriveRequestMeta(event);
165
+ const server = await createMCPServerForRequest(config, authResult.identity, requestMeta);
166
+ const { nodeReq, nodeRes } = getNodeReqRes(event);
167
+ if (nodeReq && nodeRes) {
168
+ // ---- Node fast-path (UNCHANGED behavior) --------------------------------
62
169
  const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
63
170
  const transport = new StreamableHTTPServerTransport({
64
171
  sessionIdGenerator: undefined, // stateless
65
172
  });
66
- // Derive the running app's origin so relative deep links become
67
- // absolute URLs the external agent can open (same approach as A2A).
68
- const forwardedProto = getRequestHeader(event, "x-forwarded-proto");
69
- const host = getRequestHeader(event, "host");
70
- const proto = forwardedProto?.split(",")[0]?.trim() ||
71
- (host && /^(localhost|127\.0\.0\.1)(:|$)/.test(host)
72
- ? "http"
73
- : "https");
74
- const origin = host ? `${proto}://${host}` : undefined;
75
- const targetHeader = getRequestHeader(event, "x-agent-native-open-target")?.toLowerCase();
76
- const target = targetHeader === "desktop" ||
77
- targetHeader === "terminal" ||
78
- targetHeader === "browser"
79
- ? targetHeader
80
- : undefined;
81
- const server = await createMCPServerForRequest(config, authResult.identity, { origin, target });
82
173
  await server.connect(transport);
83
- // Delegate to the transport — it writes directly to the Node response.
84
- // MCP's HTTP transport requires Node streams; this route is Node-only.
85
- const nodeReq = event.node?.req ?? event.req?.runtime?.node?.req;
86
- const nodeRes = event.node?.res ?? event.req?.runtime?.node?.res;
87
- if (!nodeReq || !nodeRes) {
88
- setResponseStatus(event, 501);
89
- return { error: "MCP requires Node runtime" };
90
- }
91
174
  try {
175
+ // The SDK transport writes directly to the Node response. Node-only by
176
+ // construction; we only reach here when real Node req/res exist.
92
177
  await transport.handleRequest(nodeReq, nodeRes, body);
93
178
  }
94
179
  catch (err) {
95
- // The SDK transport writes directly to the Node response. If the
96
- // socket is already closed/ended (client disconnected, or the host
97
- // stream layer also flushed), Node throws ERR_STREAM_WRITE_AFTER_END
98
- // *after* the MCP payload was already delivered correctly. Swallow
99
- // that benign post-flush write so an external agent disconnecting
100
- // mid-stream can never take down the server process; rethrow
101
- // anything else.
180
+ // The SDK transport writes directly to the Node response. If the socket
181
+ // is already closed/ended (client disconnected, or the host stream
182
+ // layer also flushed), Node throws ERR_STREAM_WRITE_AFTER_END *after*
183
+ // the MCP payload was already delivered correctly. Swallow that benign
184
+ // post-flush write so an external agent disconnecting mid-stream can
185
+ // never take down the server process; rethrow anything else.
102
186
  if (err?.code !== "ERR_STREAM_WRITE_AFTER_END")
103
187
  throw err;
104
188
  if (process.env.DEBUG)
@@ -106,6 +190,53 @@ export function mountMCP(nitroApp, config, routePrefix = "/_agent-native") {
106
190
  }
107
191
  // Prevent H3 from double-writing the response
108
192
  event._handled = true;
193
+ return undefined;
194
+ }
195
+ // ---- Web-standard fallback (Nitro 3 / Netlify web runtime, CF, Deno,
196
+ // Bun) ---------------------------------------------------------------------
197
+ //
198
+ // `StreamableHTTPServerTransport` is itself just a thin wrapper that
199
+ // converts the Node req/res to a web Request/Response and delegates to
200
+ // `WebStandardStreamableHTTPServerTransport.handleRequest(webRequest, {
201
+ // parsedBody })`. Using the web transport directly with the SAME options +
202
+ // the same pre-read `parsedBody` produces byte-identical protocol output
203
+ // (including the deep-link `_meta` built inside createMCPServerForRequest),
204
+ // and works on every web runtime because it returns a Web `Response`
205
+ // (JSON for request/response, or an SSE `ReadableStream` body which h3
206
+ // streams natively).
207
+ const { WebStandardStreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
208
+ const transport = new WebStandardStreamableHTTPServerTransport({
209
+ sessionIdGenerator: undefined, // stateless — same as the Node path
210
+ });
211
+ await server.connect(transport);
212
+ const webRequest = buildWebRequest(event, method);
213
+ // `parsedBody: undefined` would make the SDK try to read `req.json()`; our
214
+ // synthesized request has no body, so only pass the option for POST (where
215
+ // we actually have a parsed body). For GET the transport reads no body.
216
+ const response = await transport.handleRequest(webRequest, method === "POST" ? { parsedBody: body } : undefined);
217
+ return response;
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // mountMCP — register MCP Streamable HTTP endpoint on H3/Nitro
221
+ // ---------------------------------------------------------------------------
222
+ /**
223
+ * Mount an MCP remote server on an H3/Nitro app.
224
+ *
225
+ * Endpoint: `{routePrefix}/mcp` (default `/_agent-native/mcp`)
226
+ *
227
+ * Uses stateless Streamable HTTP transport — no in-memory sessions,
228
+ * compatible with serverless deployments. Runtime-agnostic: a real Node
229
+ * server uses the SDK's Node transport; the web-standard runtime (Nitro 3 /
230
+ * Netlify web runtime, Cloudflare, Deno, Bun) uses the SDK's web-standard
231
+ * transport. Both build the same server and produce identical JSON-RPC
232
+ * output.
233
+ *
234
+ * Auth: Bearer token matching ACCESS_TOKEN/ACCESS_TOKENS or JWT via A2A_SECRET.
235
+ * No auth required when neither is configured (dev mode).
236
+ */
237
+ export function mountMCP(nitroApp, config, routePrefix = "/_agent-native") {
238
+ getH3App(nitroApp).use(`${routePrefix}/mcp`, defineEventHandler(async (event) => {
239
+ return handleMcpRequest(event, config);
109
240
  }));
110
241
  if (process.env.DEBUG)
111
242
  console.log(`[mcp] Mounted MCP server at ${routePrefix}/mcp (${Object.keys(config.actions).length} tools${config.askAgent ? " + ask-agent" : ""})`);