@agent-native/core 0.104.0 → 0.106.0

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 (152) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +64 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AgentPanel.tsx +1 -1
  5. package/corpus/core/src/client/AssistantChat.tsx +4 -0
  6. package/corpus/core/src/client/MultiTabAssistantChat.tsx +5 -1
  7. package/corpus/core/src/client/composer/PromptComposer.tsx +8 -0
  8. package/corpus/core/src/client/composer/TiptapComposer.tsx +38 -1
  9. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +107 -32
  10. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +33 -0
  11. package/corpus/core/src/client/resources/use-mcp-servers.ts +1 -0
  12. package/corpus/core/src/client/use-chat-models.ts +10 -2
  13. package/corpus/core/src/connections/catalog.ts +107 -6
  14. package/corpus/core/src/deploy/build.ts +23 -0
  15. package/corpus/core/src/localization/default-messages.ts +2 -0
  16. package/corpus/core/src/mcp/actions/call-mcp-tool.ts +18 -0
  17. package/corpus/core/src/mcp/actions/list-mcp-tools.ts +18 -0
  18. package/corpus/core/src/mcp-client/app-api.ts +136 -0
  19. package/corpus/core/src/mcp-client/index.ts +21 -0
  20. package/corpus/core/src/mcp-client/oauth-client.ts +462 -0
  21. package/corpus/core/src/mcp-client/oauth-routes.ts +307 -0
  22. package/corpus/core/src/mcp-client/remote-store.ts +93 -118
  23. package/corpus/core/src/mcp-client/remote-url.ts +97 -0
  24. package/corpus/core/src/mcp-client/routes.ts +11 -0
  25. package/corpus/core/src/oauth-tokens/store.ts +10 -4
  26. package/corpus/core/src/provider-api/index.ts +690 -55
  27. package/corpus/core/src/secrets/register-framework-secrets.ts +30 -0
  28. package/corpus/core/src/server/action-discovery.ts +2 -0
  29. package/corpus/core/src/server/core-routes-plugin.ts +9 -1
  30. package/corpus/core/src/server/workspace-provider-oauth.ts +448 -61
  31. package/corpus/core/src/templates/default/AGENTS.md +9 -7
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
  33. package/corpus/templates/analytics/app/lib/data-source-status.ts +2 -0
  34. package/corpus/templates/analytics/app/pages/DataSources.tsx +61 -0
  35. package/corpus/templates/analytics/server/handlers/jira.ts +7 -0
  36. package/corpus/templates/analytics/server/lib/jira.ts +14 -42
  37. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +11 -0
  38. package/corpus/templates/clips/app/routes.ts +3 -1
  39. package/corpus/templates/dispatch/app/routes/integrations.tsx +57 -4
  40. package/dist/client/AgentPanel.js +1 -1
  41. package/dist/client/AgentPanel.js.map +1 -1
  42. package/dist/client/AssistantChat.d.ts +2 -0
  43. package/dist/client/AssistantChat.d.ts.map +1 -1
  44. package/dist/client/AssistantChat.js +2 -2
  45. package/dist/client/AssistantChat.js.map +1 -1
  46. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  47. package/dist/client/MultiTabAssistantChat.js +5 -2
  48. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  49. package/dist/client/composer/PromptComposer.d.ts +2 -0
  50. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  51. package/dist/client/composer/PromptComposer.js +5 -2
  52. package/dist/client/composer/PromptComposer.js.map +1 -1
  53. package/dist/client/composer/TiptapComposer.d.ts +4 -1
  54. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  55. package/dist/client/composer/TiptapComposer.js +13 -5
  56. package/dist/client/composer/TiptapComposer.js.map +1 -1
  57. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  58. package/dist/client/resources/McpIntegrationDialog.js +58 -14
  59. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  60. package/dist/client/resources/mcp-integration-catalog.d.ts +9 -0
  61. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  62. package/dist/client/resources/mcp-integration-catalog.js +13 -0
  63. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  64. package/dist/client/resources/use-mcp-servers.d.ts +1 -0
  65. package/dist/client/resources/use-mcp-servers.d.ts.map +1 -1
  66. package/dist/client/resources/use-mcp-servers.js.map +1 -1
  67. package/dist/client/use-chat-models.d.ts +1 -0
  68. package/dist/client/use-chat-models.d.ts.map +1 -1
  69. package/dist/client/use-chat-models.js +8 -2
  70. package/dist/client/use-chat-models.js.map +1 -1
  71. package/dist/collab/awareness.d.ts +2 -2
  72. package/dist/collab/awareness.d.ts.map +1 -1
  73. package/dist/collab/routes.d.ts +1 -1
  74. package/dist/connections/catalog.d.ts +78 -7
  75. package/dist/connections/catalog.d.ts.map +1 -1
  76. package/dist/connections/catalog.js +97 -6
  77. package/dist/connections/catalog.js.map +1 -1
  78. package/dist/deploy/build.d.ts.map +1 -1
  79. package/dist/deploy/build.js +17 -0
  80. package/dist/deploy/build.js.map +1 -1
  81. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  82. package/dist/localization/default-messages.d.ts +2 -0
  83. package/dist/localization/default-messages.d.ts.map +1 -1
  84. package/dist/localization/default-messages.js +2 -0
  85. package/dist/localization/default-messages.js.map +1 -1
  86. package/dist/mcp/actions/call-mcp-tool.d.ts +7 -0
  87. package/dist/mcp/actions/call-mcp-tool.d.ts.map +1 -0
  88. package/dist/mcp/actions/call-mcp-tool.js +15 -0
  89. package/dist/mcp/actions/call-mcp-tool.js.map +1 -0
  90. package/dist/mcp/actions/list-mcp-tools.d.ts +5 -0
  91. package/dist/mcp/actions/list-mcp-tools.d.ts.map +1 -0
  92. package/dist/mcp/actions/list-mcp-tools.js +16 -0
  93. package/dist/mcp/actions/list-mcp-tools.js.map +1 -0
  94. package/dist/mcp-client/app-api.d.ts +34 -0
  95. package/dist/mcp-client/app-api.d.ts.map +1 -0
  96. package/dist/mcp-client/app-api.js +94 -0
  97. package/dist/mcp-client/app-api.js.map +1 -0
  98. package/dist/mcp-client/index.d.ts +3 -1
  99. package/dist/mcp-client/index.d.ts.map +1 -1
  100. package/dist/mcp-client/index.js +3 -1
  101. package/dist/mcp-client/index.js.map +1 -1
  102. package/dist/mcp-client/oauth-client.d.ts +113 -0
  103. package/dist/mcp-client/oauth-client.d.ts.map +1 -0
  104. package/dist/mcp-client/oauth-client.js +316 -0
  105. package/dist/mcp-client/oauth-client.js.map +1 -0
  106. package/dist/mcp-client/oauth-routes.d.ts +25 -0
  107. package/dist/mcp-client/oauth-routes.d.ts.map +1 -0
  108. package/dist/mcp-client/oauth-routes.js +218 -0
  109. package/dist/mcp-client/oauth-routes.js.map +1 -0
  110. package/dist/mcp-client/remote-store.d.ts +21 -6
  111. package/dist/mcp-client/remote-store.d.ts.map +1 -1
  112. package/dist/mcp-client/remote-store.js +72 -124
  113. package/dist/mcp-client/remote-store.js.map +1 -1
  114. package/dist/mcp-client/remote-url.d.ts +8 -0
  115. package/dist/mcp-client/remote-url.d.ts.map +1 -0
  116. package/dist/mcp-client/remote-url.js +103 -0
  117. package/dist/mcp-client/remote-url.js.map +1 -0
  118. package/dist/mcp-client/routes.d.ts +1 -0
  119. package/dist/mcp-client/routes.d.ts.map +1 -1
  120. package/dist/mcp-client/routes.js +9 -0
  121. package/dist/mcp-client/routes.js.map +1 -1
  122. package/dist/notifications/routes.d.ts +3 -3
  123. package/dist/oauth-tokens/store.d.ts +2 -2
  124. package/dist/oauth-tokens/store.d.ts.map +1 -1
  125. package/dist/oauth-tokens/store.js +10 -6
  126. package/dist/oauth-tokens/store.js.map +1 -1
  127. package/dist/observability/routes.d.ts +5 -5
  128. package/dist/progress/routes.d.ts +1 -1
  129. package/dist/provider-api/index.d.ts +14 -0
  130. package/dist/provider-api/index.d.ts.map +1 -1
  131. package/dist/provider-api/index.js +499 -61
  132. package/dist/provider-api/index.js.map +1 -1
  133. package/dist/resources/handlers.d.ts +1 -1
  134. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  135. package/dist/secrets/register-framework-secrets.js +28 -0
  136. package/dist/secrets/register-framework-secrets.js.map +1 -1
  137. package/dist/secrets/routes.d.ts +9 -9
  138. package/dist/server/action-discovery.d.ts.map +1 -1
  139. package/dist/server/action-discovery.js +2 -0
  140. package/dist/server/action-discovery.js.map +1 -1
  141. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  142. package/dist/server/core-routes-plugin.js +9 -1
  143. package/dist/server/core-routes-plugin.js.map +1 -1
  144. package/dist/server/workspace-provider-oauth.d.ts +10 -3
  145. package/dist/server/workspace-provider-oauth.d.ts.map +1 -1
  146. package/dist/server/workspace-provider-oauth.js +325 -43
  147. package/dist/server/workspace-provider-oauth.js.map +1 -1
  148. package/dist/templates/default/AGENTS.md +9 -7
  149. package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
  150. package/package.json +2 -2
  151. package/src/templates/default/AGENTS.md +9 -7
  152. package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
@@ -0,0 +1,307 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import type { OAuthClientInformationMixed } from "@modelcontextprotocol/sdk/shared/auth.js";
4
+ import {
5
+ deleteCookie,
6
+ defineEventHandler,
7
+ getCookie,
8
+ getMethod,
9
+ getQuery,
10
+ setCookie,
11
+ setResponseStatus,
12
+ type H3Event,
13
+ } from "h3";
14
+
15
+ import { getOrgContext } from "../org/context.js";
16
+ import { decryptSecretValue, encryptSecretValue } from "../secrets/crypto.js";
17
+ import { getSession, safeReturnPath } from "../server/auth.js";
18
+ import { getH3App } from "../server/framework-request-handler.js";
19
+ import { getAppUrl, resolveOAuthRedirectUri } from "../server/google-oauth.js";
20
+ import { runWithRequestContext } from "../server/request-context.js";
21
+ import {
22
+ finishMcpOAuthAuthorization,
23
+ startMcpOAuthAuthorization,
24
+ type McpOAuthDiscoveryState,
25
+ } from "./oauth-client.js";
26
+ import {
27
+ addOAuthRemoteServer,
28
+ normalizeServerName,
29
+ validateRemoteUrl,
30
+ type RemoteMcpScope,
31
+ } from "./remote-store.js";
32
+
33
+ const FLOW_COOKIE = "an_mcp_oauth_flow";
34
+ const FLOW_TTL_SECONDS = 10 * 60;
35
+
36
+ export interface McpOAuthFlow {
37
+ name: string;
38
+ url: string;
39
+ description?: string;
40
+ scope: RemoteMcpScope;
41
+ scopeId: string;
42
+ owner: string;
43
+ orgId?: string;
44
+ redirectUri: string;
45
+ state: string;
46
+ codeVerifier: string;
47
+ clientInformation: OAuthClientInformationMixed;
48
+ discoveryState?: McpOAuthDiscoveryState;
49
+ returnUrl?: string;
50
+ expiresAt: number;
51
+ }
52
+
53
+ export interface McpOAuthRoutesOptions {
54
+ reconfigure: () => Promise<void>;
55
+ }
56
+
57
+ export function mountMcpOAuthRoutes(
58
+ nitroApp: any,
59
+ options: McpOAuthRoutesOptions,
60
+ ): void {
61
+ const mountedApps: WeakSet<object> = ((
62
+ globalThis as any
63
+ ).__agentNativeMcpOAuthMountedApps ??= new WeakSet<object>());
64
+ if (mountedApps.has(nitroApp)) return;
65
+ mountedApps.add(nitroApp);
66
+
67
+ getH3App(nitroApp).use(
68
+ "/_agent-native/mcp/servers/oauth",
69
+ defineEventHandler(async (event: H3Event) => {
70
+ const method = getMethod(event);
71
+ const pathname = (event.url?.pathname || "")
72
+ .replace(/^\/+/, "")
73
+ .replace(/\/+$/, "");
74
+ const parts = pathname ? pathname.split("/") : [];
75
+ if (method !== "GET") {
76
+ setResponseStatus(event, 405);
77
+ return { error: "Method not allowed" };
78
+ }
79
+ if (parts.length === 1 && parts[0] === "start") {
80
+ return handleMcpOAuthStart(event);
81
+ }
82
+ if (parts.length === 1 && parts[0] === "callback") {
83
+ return handleMcpOAuthCallback(event, options);
84
+ }
85
+ setResponseStatus(event, 404);
86
+ return { error: "Not found" };
87
+ }),
88
+ );
89
+ }
90
+
91
+ async function handleMcpOAuthStart(
92
+ event: H3Event,
93
+ ): Promise<Response | Record<string, unknown>> {
94
+ const session = await getSession(event).catch(() => null);
95
+ if (!session?.email) return unauthorized(event);
96
+
97
+ const query = getQuery(event);
98
+ const rawUrl = text(query.url);
99
+ const rawName = text(query.name);
100
+ const returnUrl = text(query.return);
101
+ if (!rawUrl || !rawName) {
102
+ setResponseStatus(event, 400);
103
+ return { error: "MCP OAuth requires a server name and URL." };
104
+ }
105
+ const urlCheck = validateRemoteUrl(rawUrl);
106
+ if (!urlCheck.ok) {
107
+ setResponseStatus(event, 400);
108
+ return { error: urlCheck.error ?? "MCP server URL is not allowed." };
109
+ }
110
+ const name = normalizeServerName(rawName);
111
+ if (!name) {
112
+ setResponseStatus(event, 400);
113
+ return { error: "MCP server name is invalid." };
114
+ }
115
+
116
+ const requestedScope = query.scope === "org" ? "org" : "user";
117
+ const requestedOrgId = text(query.orgId);
118
+ const org =
119
+ requestedScope === "org"
120
+ ? await getOrgContext(event).catch(() => null)
121
+ : null;
122
+ const scope: RemoteMcpScope = requestedScope;
123
+ const scopeId = scope === "user" ? session.email : (org?.orgId ?? "");
124
+ if (scope === "org" && requestedOrgId && requestedOrgId !== scopeId) {
125
+ setResponseStatus(event, 403);
126
+ return {
127
+ error: "The selected organization is not the active organization.",
128
+ };
129
+ }
130
+ if (scope === "org" && (!scopeId || !isOrgAdmin(org?.role))) {
131
+ setResponseStatus(event, scopeId ? 403 : 400);
132
+ return {
133
+ error: scopeId
134
+ ? "Only organization owners and admins can connect an org MCP server."
135
+ : "Join an organization before connecting an org MCP server.",
136
+ };
137
+ }
138
+
139
+ const redirectUri = resolveOAuthRedirectUri(
140
+ event,
141
+ "/_agent-native/mcp/servers/oauth/callback",
142
+ );
143
+ if (!redirectUri) {
144
+ setResponseStatus(event, 400);
145
+ return { error: "Invalid MCP OAuth redirect URI." };
146
+ }
147
+
148
+ const state = crypto.randomUUID();
149
+ const safeReturnUrl = returnUrl ? safeReturnPath(returnUrl) : undefined;
150
+ try {
151
+ const started = await runWithRequestContext(
152
+ { userEmail: session.email, orgId: org?.orgId ?? undefined },
153
+ () =>
154
+ startMcpOAuthAuthorization({
155
+ serverUrl: urlCheck.url!.toString(),
156
+ redirectUrl: redirectUri,
157
+ state,
158
+ }),
159
+ );
160
+ const flow: McpOAuthFlow = {
161
+ name,
162
+ url: urlCheck.url!.toString(),
163
+ description: text(query.description),
164
+ scope,
165
+ scopeId,
166
+ owner: session.email,
167
+ ...(org?.orgId ? { orgId: org.orgId } : {}),
168
+ redirectUri,
169
+ state: started.state,
170
+ codeVerifier: started.codeVerifier,
171
+ clientInformation: started.clientInformation,
172
+ ...(started.discoveryState
173
+ ? { discoveryState: started.discoveryState }
174
+ : {}),
175
+ ...(safeReturnUrl ? { returnUrl: safeReturnUrl } : {}),
176
+ expiresAt: Date.now() + FLOW_TTL_SECONDS * 1_000,
177
+ };
178
+ setCookie(event, FLOW_COOKIE, encryptSecretValue(JSON.stringify(flow)), {
179
+ httpOnly: true,
180
+ secure: redirectUri.startsWith("https://"),
181
+ sameSite: "lax",
182
+ path: "/",
183
+ maxAge: FLOW_TTL_SECONDS,
184
+ });
185
+ return Response.redirect(started.authorizationUrl.href, 302);
186
+ } catch {
187
+ setResponseStatus(event, 400);
188
+ return {
189
+ error:
190
+ "This MCP server could not start OAuth. It may not support standard MCP OAuth discovery or dynamic client registration.",
191
+ };
192
+ }
193
+ }
194
+
195
+ async function handleMcpOAuthCallback(
196
+ event: H3Event,
197
+ options: McpOAuthRoutesOptions,
198
+ ): Promise<Response | Record<string, unknown>> {
199
+ const session = await getSession(event).catch(() => null);
200
+ if (!session?.email) return unauthorized(event);
201
+
202
+ const query = getQuery(event);
203
+ const code = text(query.code);
204
+ const state = text(query.state);
205
+ const providerError = text(query.error);
206
+ const flow = readFlow(event);
207
+ deleteCookie(event, FLOW_COOKIE, { path: "/" });
208
+ if (providerError || !code || !state) {
209
+ setResponseStatus(event, 400);
210
+ return { error: "MCP OAuth authorization was not completed." };
211
+ }
212
+ const org =
213
+ flow?.scope === "org" ? await getOrgContext(event).catch(() => null) : null;
214
+ if (
215
+ !flow ||
216
+ !isValidMcpOAuthFlow(flow, session.email, org?.orgId ?? undefined, state)
217
+ ) {
218
+ setResponseStatus(event, 400);
219
+ return { error: "MCP OAuth state is invalid or expired." };
220
+ }
221
+ if (flow.scope === "org" && !isOrgAdmin(org?.role)) {
222
+ setResponseStatus(event, 403);
223
+ return {
224
+ error:
225
+ "Only organization owners and admins can connect an org MCP server.",
226
+ };
227
+ }
228
+
229
+ try {
230
+ const finished = await runWithRequestContext(
231
+ { userEmail: session.email, orgId: org?.orgId ?? undefined },
232
+ () =>
233
+ finishMcpOAuthAuthorization({
234
+ serverUrl: flow.url,
235
+ redirectUrl: flow.redirectUri,
236
+ state: flow.state,
237
+ clientInformation: flow.clientInformation,
238
+ codeVerifier: flow.codeVerifier,
239
+ discoveryState: flow.discoveryState,
240
+ authorizationCode: code,
241
+ }),
242
+ );
243
+ const result = await addOAuthRemoteServer(flow.scope, flow.scopeId, {
244
+ name: flow.name,
245
+ url: flow.url,
246
+ description: flow.description,
247
+ credentials: finished.credentials,
248
+ });
249
+ if (!result.ok) {
250
+ setResponseStatus(event, 400);
251
+ return { error: result.error };
252
+ }
253
+ await options.reconfigure();
254
+ const returnPath =
255
+ flow.returnUrl ??
256
+ `/settings/connections?connected=mcp-${encodeURIComponent(flow.name)}`;
257
+ return Response.redirect(getAppUrl(event, returnPath), 302);
258
+ } catch {
259
+ setResponseStatus(event, 400);
260
+ return { error: "MCP OAuth authorization could not be completed." };
261
+ }
262
+ }
263
+
264
+ function readFlow(event: H3Event): McpOAuthFlow | null {
265
+ const encrypted = getCookie(event, FLOW_COOKIE);
266
+ if (!encrypted) return null;
267
+ try {
268
+ const parsed = JSON.parse(decryptSecretValue(encrypted)) as McpOAuthFlow;
269
+ return parsed && typeof parsed === "object" ? parsed : null;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ export function isValidMcpOAuthFlow(
276
+ flow: McpOAuthFlow,
277
+ email: string,
278
+ orgId: string | undefined,
279
+ state: string,
280
+ ): boolean {
281
+ const scopeMatches =
282
+ flow.scope === "user"
283
+ ? flow.scopeId === email && !flow.orgId
284
+ : flow.scope === "org" && flow.scopeId === orgId && flow.orgId === orgId;
285
+ return (
286
+ flow.expiresAt >= Date.now() &&
287
+ flow.owner === email &&
288
+ flow.state === state &&
289
+ scopeMatches &&
290
+ typeof flow.scopeId === "string" &&
291
+ typeof flow.redirectUri === "string" &&
292
+ flow.redirectUri.includes("/_agent-native/mcp/servers/oauth/callback")
293
+ );
294
+ }
295
+
296
+ function isOrgAdmin(role: unknown): boolean {
297
+ return role === "owner" || role === "admin";
298
+ }
299
+
300
+ function text(value: unknown): string | undefined {
301
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
302
+ }
303
+
304
+ function unauthorized(event: H3Event) {
305
+ setResponseStatus(event, 401);
306
+ return { error: "Authentication required" };
307
+ }
@@ -39,6 +39,15 @@ import {
39
39
  deleteUserSetting,
40
40
  } from "../settings/user-settings.js";
41
41
  import type { McpHttpServerConfig } from "./config.js";
42
+ import {
43
+ deleteMcpOAuthCredentials,
44
+ getMcpOAuthAccessToken,
45
+ saveMcpOAuthCredentials,
46
+ type McpOAuthCredentialBundle,
47
+ } from "./oauth-client.js";
48
+ import { validateRemoteUrl } from "./remote-url.js";
49
+
50
+ export { validateRemoteUrl } from "./remote-url.js";
42
51
 
43
52
  const SETTINGS_KEY = "mcp-servers-remote";
44
53
 
@@ -70,6 +79,8 @@ export interface StoredRemoteMcpServer {
70
79
  * supplied (or for legacy cleartext rows).
71
80
  */
72
81
  headerSecretKey?: string;
82
+ /** Reference to the encrypted OAuth credential bundle for this server. */
83
+ oauthSecretKey?: string;
73
84
  /**
74
85
  * Trusted first-party Agent-Native app. Only framework-controlled
75
86
  * registrations should set this; management routes intentionally do not
@@ -167,123 +178,6 @@ function sanitiseOrgId(orgId: string): string {
167
178
  return orgId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
168
179
  }
169
180
 
170
- /**
171
- * Hostname patterns that are always rejected to prevent SSRF via DNS rebinding
172
- * or well-known internal service names.
173
- */
174
- const BLOCKED_HOSTNAME_PATTERNS: RegExp[] = [
175
- /^localhost$/i,
176
- /\.localhost$/i,
177
- /\.local$/i,
178
- /\.internal$/i,
179
- // DNS rebind services
180
- /\.nip\.io$/i,
181
- /\.sslip\.io$/i,
182
- /\.xip\.io$/i,
183
- /\.localtest\.me$/i,
184
- /\.lvh\.me$/i,
185
- // Cloud metadata hosts
186
- /^metadata\.google\.internal$/i,
187
- /^instance-data$/i,
188
- ];
189
-
190
- /** Literal IPs that are always rejected (cloud metadata endpoints). */
191
- const BLOCKED_IPS = new Set([
192
- "169.254.169.254", // AWS/GCP/Azure IMDS
193
- "100.100.100.200", // Alibaba Cloud metadata
194
- "::1",
195
- ]);
196
-
197
- /** Parse a dotted-decimal IPv4 string to a 32-bit integer for range checks. */
198
- function ipv4ToInt(ip: string): number | null {
199
- const parts = ip.split(".");
200
- if (parts.length !== 4) return null;
201
- let n = 0;
202
- for (const part of parts) {
203
- const byte = parseInt(part, 10);
204
- if (isNaN(byte) || byte < 0 || byte > 255) return null;
205
- n = (n << 8) | byte;
206
- }
207
- return n >>> 0;
208
- }
209
-
210
- function isPrivateIpv4(hostname: string): boolean {
211
- const n = ipv4ToInt(hostname);
212
- if (n === null) return false;
213
- // 10.0.0.0/8
214
- if ((n & 0xff000000) >>> 0 === 0x0a000000) return true;
215
- // 172.16.0.0/12
216
- if ((n & 0xfff00000) >>> 0 === 0xac100000) return true;
217
- // 192.168.0.0/16
218
- if ((n & 0xffff0000) >>> 0 === 0xc0a80000) return true;
219
- // 169.254.0.0/16 (link-local, includes AWS/GCP metadata)
220
- if ((n & 0xffff0000) >>> 0 === 0xa9fe0000) return true;
221
- // 127.0.0.0/8 (loopback)
222
- if ((n & 0xff000000) >>> 0 === 0x7f000000) return true;
223
- return false;
224
- }
225
-
226
- function isBlockedHostname(hostname: string): boolean {
227
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
228
- if (BLOCKED_IPS.has(normalized)) return true;
229
- if (isPrivateIpv4(normalized)) return true;
230
- // IPv6 ULA (fc00::/7) and link-local (fe80::/10)
231
- if (normalized === "::1") return true;
232
- if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
233
- if (normalized.startsWith("fe80")) return true;
234
- if (normalized.startsWith("::ffff:")) {
235
- const mapped = normalized.slice("::ffff:".length);
236
- if (isPrivateIpv4(mapped)) return true;
237
- const hexMatch = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(mapped);
238
- if (hexMatch) {
239
- const value =
240
- (parseInt(hexMatch[1], 16) << 16) | parseInt(hexMatch[2], 16);
241
- const dotted = [
242
- (value >>> 24) & 0xff,
243
- (value >>> 16) & 0xff,
244
- (value >>> 8) & 0xff,
245
- value & 0xff,
246
- ].join(".");
247
- if (isPrivateIpv4(dotted)) return true;
248
- }
249
- }
250
- for (const pattern of BLOCKED_HOSTNAME_PATTERNS) {
251
- if (pattern.test(normalized)) return true;
252
- }
253
- return false;
254
- }
255
-
256
- /** Reject obviously-wrong URLs. Allows http only for localhost. */
257
- export function validateRemoteUrl(raw: string): {
258
- ok: boolean;
259
- url?: URL;
260
- error?: string;
261
- } {
262
- let url: URL;
263
- try {
264
- url = new URL(raw);
265
- } catch {
266
- return { ok: false, error: "Not a valid URL" };
267
- }
268
- if (url.protocol === "https:") {
269
- if (isBlockedHostname(url.hostname)) {
270
- return {
271
- ok: false,
272
- error: `Host "${url.hostname}" is not allowed (private/internal address)`,
273
- };
274
- }
275
- return { ok: true, url };
276
- }
277
- if (url.protocol === "http:") {
278
- const host = url.hostname;
279
- if (host === "localhost" || host === "127.0.0.1") {
280
- return { ok: true, url };
281
- }
282
- return { ok: false, error: "Plain http is only allowed for localhost" };
283
- }
284
- return { ok: false, error: `Unsupported protocol: ${url.protocol}` };
285
- }
286
-
287
181
  async function readList(
288
182
  scope: RemoteMcpScope,
289
183
  scopeId: string,
@@ -340,6 +234,58 @@ export async function addRemoteServer(
340
234
  return addRemoteServerInternal(scope, scopeId, input);
341
235
  }
342
236
 
237
+ /**
238
+ * Persist a remote MCP server whose authorization is managed by the MCP
239
+ * server's OAuth 2.1 metadata and token endpoints. OAuth credentials are
240
+ * stored separately from the settings row and never returned to clients.
241
+ */
242
+ export async function addOAuthRemoteServer(
243
+ scope: RemoteMcpScope,
244
+ scopeId: string,
245
+ input: {
246
+ name: string;
247
+ url: string;
248
+ description?: string;
249
+ credentials: McpOAuthCredentialBundle;
250
+ },
251
+ ): Promise<
252
+ { ok: true; server: StoredRemoteMcpServer } | { ok: false; error: string }
253
+ > {
254
+ const oauthSecretKey = `mcp_oauth:${shortId()}`;
255
+ try {
256
+ await saveMcpOAuthCredentials({
257
+ key: oauthSecretKey,
258
+ scope,
259
+ scopeId,
260
+ credentials: input.credentials,
261
+ });
262
+ const result = await addRemoteServerInternal(scope, scopeId, {
263
+ name: input.name,
264
+ url: input.url,
265
+ description: input.description,
266
+ oauthSecretKey,
267
+ });
268
+ if (!result.ok) {
269
+ await deleteMcpOAuthCredentials({
270
+ key: oauthSecretKey,
271
+ scope,
272
+ scopeId,
273
+ });
274
+ }
275
+ return result;
276
+ } catch (err: any) {
277
+ await deleteMcpOAuthCredentials({
278
+ key: oauthSecretKey,
279
+ scope,
280
+ scopeId,
281
+ }).catch(() => {});
282
+ return {
283
+ ok: false,
284
+ error: `Failed to save MCP OAuth credentials: ${err?.message ?? err}`,
285
+ };
286
+ }
287
+ }
288
+
343
289
  export async function addFirstPartyRemoteServer(
344
290
  orgId: string,
345
291
  input: {
@@ -376,6 +322,7 @@ async function addRemoteServerInternal(
376
322
  description?: string;
377
323
  firstParty?: boolean;
378
324
  firstPartyAppId?: string;
325
+ oauthSecretKey?: string;
379
326
  },
380
327
  ): Promise<
381
328
  { ok: true; server: StoredRemoteMcpServer } | { ok: false; error: string }
@@ -427,6 +374,7 @@ async function addRemoteServerInternal(
427
374
  ...(input.firstPartyAppId
428
375
  ? { firstPartyAppId: input.firstPartyAppId }
429
376
  : {}),
377
+ ...(input.oauthSecretKey ? { oauthSecretKey: input.oauthSecretKey } : {}),
430
378
  description: input.description?.trim() || undefined,
431
379
  createdAt: Date.now(),
432
380
  };
@@ -546,6 +494,20 @@ export async function removeRemoteServer(
546
494
  );
547
495
  }
548
496
  }
497
+ if (removed?.oauthSecretKey) {
498
+ try {
499
+ await deleteMcpOAuthCredentials({
500
+ key: removed.oauthSecretKey,
501
+ scope,
502
+ scopeId,
503
+ });
504
+ } catch (err: any) {
505
+ // eslint-disable-next-line no-console
506
+ console.warn(
507
+ `[mcp-client] Failed to delete MCP OAuth credentials ${removed.oauthSecretKey}: ${err?.message ?? err}`,
508
+ );
509
+ }
510
+ }
549
511
  return true;
550
512
  }
551
513
 
@@ -628,10 +590,23 @@ export async function toHttpServerConfigAsync(
628
590
  scopeId: string,
629
591
  stored: StoredRemoteMcpServer,
630
592
  ): Promise<McpHttpServerConfig> {
593
+ let headers = await materializeHeaders(scope, scopeId, stored);
594
+ if (stored.oauthSecretKey) {
595
+ const accessToken = await getMcpOAuthAccessToken({
596
+ key: stored.oauthSecretKey,
597
+ scope,
598
+ scopeId,
599
+ serverUrl: stored.url,
600
+ });
601
+ if (accessToken) {
602
+ headers ??= {};
603
+ headers.Authorization = `Bearer ${accessToken}`;
604
+ }
605
+ }
631
606
  return {
632
607
  type: "http",
633
608
  url: stored.url,
634
- headers: await materializeHeaders(scope, scopeId, stored),
609
+ headers,
635
610
  ...(stored.firstParty ? { firstParty: true } : {}),
636
611
  ...(stored.firstPartyAppId
637
612
  ? { firstPartyAppId: stored.firstPartyAppId }
@@ -0,0 +1,97 @@
1
+ /** URL validation shared by remote MCP storage and OAuth discovery. */
2
+
3
+ const BLOCKED_HOSTNAME_PATTERNS: RegExp[] = [
4
+ /^localhost$/i,
5
+ /\.localhost$/i,
6
+ /\.local$/i,
7
+ /\.internal$/i,
8
+ /\.nip\.io$/i,
9
+ /\.sslip\.io$/i,
10
+ /\.xip\.io$/i,
11
+ /\.localtest\.me$/i,
12
+ /\.lvh\.me$/i,
13
+ /^metadata\.google\.internal$/i,
14
+ /^instance-data$/i,
15
+ ];
16
+
17
+ const BLOCKED_IPS = new Set(["169.254.169.254", "100.100.100.200", "::1"]);
18
+
19
+ function ipv4ToInt(ip: string): number | null {
20
+ const parts = ip.split(".");
21
+ if (parts.length !== 4) return null;
22
+ let n = 0;
23
+ for (const part of parts) {
24
+ const byte = parseInt(part, 10);
25
+ if (isNaN(byte) || byte < 0 || byte > 255) return null;
26
+ n = (n << 8) | byte;
27
+ }
28
+ return n >>> 0;
29
+ }
30
+
31
+ function isPrivateIpv4(hostname: string): boolean {
32
+ const n = ipv4ToInt(hostname);
33
+ if (n === null) return false;
34
+ if ((n & 0xff000000) >>> 0 === 0x0a000000) return true;
35
+ if ((n & 0xfff00000) >>> 0 === 0xac100000) return true;
36
+ if ((n & 0xffff0000) >>> 0 === 0xc0a80000) return true;
37
+ if ((n & 0xffff0000) >>> 0 === 0xa9fe0000) return true;
38
+ if ((n & 0xff000000) >>> 0 === 0x7f000000) return true;
39
+ return false;
40
+ }
41
+
42
+ function isBlockedHostname(hostname: string): boolean {
43
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
44
+ if (BLOCKED_IPS.has(normalized)) return true;
45
+ if (isPrivateIpv4(normalized)) return true;
46
+ if (normalized === "::1") return true;
47
+ if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
48
+ if (normalized.startsWith("fe80")) return true;
49
+ if (normalized.startsWith("::ffff:")) {
50
+ const mapped = normalized.slice("::ffff:".length);
51
+ if (isPrivateIpv4(mapped)) return true;
52
+ const hexMatch = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(mapped);
53
+ if (hexMatch) {
54
+ const value =
55
+ (parseInt(hexMatch[1], 16) << 16) | parseInt(hexMatch[2], 16);
56
+ const dotted = [
57
+ (value >>> 24) & 0xff,
58
+ (value >>> 16) & 0xff,
59
+ (value >>> 8) & 0xff,
60
+ value & 0xff,
61
+ ].join(".");
62
+ if (isPrivateIpv4(dotted)) return true;
63
+ }
64
+ }
65
+ return BLOCKED_HOSTNAME_PATTERNS.some((pattern) => pattern.test(normalized));
66
+ }
67
+
68
+ /** Reject public-resource URLs that could target an internal network. */
69
+ export function validateRemoteUrl(raw: string): {
70
+ ok: boolean;
71
+ url?: URL;
72
+ error?: string;
73
+ } {
74
+ let url: URL;
75
+ try {
76
+ url = new URL(raw);
77
+ } catch {
78
+ return { ok: false, error: "Not a valid URL" };
79
+ }
80
+ if (url.protocol === "https:") {
81
+ if (isBlockedHostname(url.hostname)) {
82
+ return {
83
+ ok: false,
84
+ error: `Host "${url.hostname}" is not allowed (private/internal address)`,
85
+ };
86
+ }
87
+ return { ok: true, url };
88
+ }
89
+ if (url.protocol === "http:") {
90
+ const host = url.hostname;
91
+ if (host === "localhost" || host === "127.0.0.1") {
92
+ return { ok: true, url };
93
+ }
94
+ return { ok: false, error: "Plain http is only allowed for localhost" };
95
+ }
96
+ return { ok: false, error: `Unsupported protocol: ${url.protocol}` };
97
+ }
@@ -60,6 +60,7 @@ import {
60
60
  type McpClientManager,
61
61
  type McpTool,
62
62
  } from "./manager.js";
63
+ import { mountMcpOAuthRoutes } from "./oauth-routes.js";
63
64
  import {
64
65
  addRemoteServer,
65
66
  listRemoteServers,
@@ -97,6 +98,11 @@ function projectForClient(
97
98
  name: stored.name,
98
99
  url: stored.url,
99
100
  headers: redactHeaders(stored.headers),
101
+ authMode: stored.oauthSecretKey
102
+ ? "oauth"
103
+ : stored.headerSecretKey || stored.headers
104
+ ? "headers"
105
+ : "none",
100
106
  description: stored.description,
101
107
  firstParty: stored.firstParty === true,
102
108
  createdAt: stored.createdAt,
@@ -111,6 +117,7 @@ export interface ClientServer {
111
117
  name: string;
112
118
  url: string;
113
119
  headers?: Record<string, { set: true }>;
120
+ authMode: "none" | "headers" | "oauth";
114
121
  description?: string;
115
122
  firstParty?: boolean;
116
123
  createdAt: number;
@@ -367,6 +374,10 @@ export function mountMcpServersRoutes(
367
374
  if (mountedApps.has(nitroApp)) return;
368
375
  mountedApps.add(nitroApp);
369
376
 
377
+ mountMcpOAuthRoutes(nitroApp, {
378
+ reconfigure: () => reconfigureManager(manager),
379
+ });
380
+
370
381
  try {
371
382
  getH3App(nitroApp).use(
372
383
  "/_agent-native/mcp/servers",