@agent-native/core 0.105.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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +33 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +4 -0
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +5 -1
- package/corpus/core/src/client/composer/PromptComposer.tsx +8 -0
- package/corpus/core/src/client/composer/TiptapComposer.tsx +38 -1
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +107 -32
- package/corpus/core/src/client/resources/mcp-integration-catalog.ts +33 -0
- package/corpus/core/src/client/resources/use-mcp-servers.ts +1 -0
- package/corpus/core/src/client/use-chat-models.ts +10 -2
- package/corpus/core/src/connections/catalog.ts +107 -6
- package/corpus/core/src/localization/default-messages.ts +2 -0
- package/corpus/core/src/mcp/actions/call-mcp-tool.ts +18 -0
- package/corpus/core/src/mcp/actions/list-mcp-tools.ts +18 -0
- package/corpus/core/src/mcp-client/app-api.ts +136 -0
- package/corpus/core/src/mcp-client/index.ts +21 -0
- package/corpus/core/src/mcp-client/oauth-client.ts +462 -0
- package/corpus/core/src/mcp-client/oauth-routes.ts +307 -0
- package/corpus/core/src/mcp-client/remote-store.ts +93 -118
- package/corpus/core/src/mcp-client/remote-url.ts +97 -0
- package/corpus/core/src/mcp-client/routes.ts +11 -0
- package/corpus/core/src/oauth-tokens/store.ts +10 -4
- package/corpus/core/src/provider-api/index.ts +690 -55
- package/corpus/core/src/secrets/register-framework-secrets.ts +30 -0
- package/corpus/core/src/server/action-discovery.ts +2 -0
- package/corpus/core/src/server/core-routes-plugin.ts +9 -1
- package/corpus/core/src/server/workspace-provider-oauth.ts +448 -61
- package/corpus/core/src/templates/default/AGENTS.md +9 -7
- package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
- package/corpus/templates/analytics/app/lib/data-source-status.ts +2 -0
- package/corpus/templates/analytics/app/pages/DataSources.tsx +61 -0
- package/corpus/templates/analytics/server/handlers/jira.ts +7 -0
- package/corpus/templates/analytics/server/lib/jira.ts +14 -42
- package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +11 -0
- package/corpus/templates/dispatch/app/routes/integrations.tsx +57 -4
- package/dist/client/AssistantChat.d.ts +2 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +2 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +5 -2
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/composer/PromptComposer.d.ts +2 -0
- package/dist/client/composer/PromptComposer.d.ts.map +1 -1
- package/dist/client/composer/PromptComposer.js +5 -2
- package/dist/client/composer/PromptComposer.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +4 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +13 -5
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +58 -14
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.d.ts +9 -0
- package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.js +13 -0
- package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
- package/dist/client/resources/use-mcp-servers.d.ts +1 -0
- package/dist/client/resources/use-mcp-servers.d.ts.map +1 -1
- package/dist/client/resources/use-mcp-servers.js.map +1 -1
- package/dist/client/use-chat-models.d.ts +1 -0
- package/dist/client/use-chat-models.d.ts.map +1 -1
- package/dist/client/use-chat-models.js +8 -2
- package/dist/client/use-chat-models.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/connections/catalog.d.ts +78 -7
- package/dist/connections/catalog.d.ts.map +1 -1
- package/dist/connections/catalog.js +97 -6
- package/dist/connections/catalog.js.map +1 -1
- package/dist/localization/default-messages.d.ts +2 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +2 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/mcp/actions/call-mcp-tool.d.ts +7 -0
- package/dist/mcp/actions/call-mcp-tool.d.ts.map +1 -0
- package/dist/mcp/actions/call-mcp-tool.js +15 -0
- package/dist/mcp/actions/call-mcp-tool.js.map +1 -0
- package/dist/mcp/actions/list-mcp-tools.d.ts +5 -0
- package/dist/mcp/actions/list-mcp-tools.d.ts.map +1 -0
- package/dist/mcp/actions/list-mcp-tools.js +16 -0
- package/dist/mcp/actions/list-mcp-tools.js.map +1 -0
- package/dist/mcp-client/app-api.d.ts +34 -0
- package/dist/mcp-client/app-api.d.ts.map +1 -0
- package/dist/mcp-client/app-api.js +94 -0
- package/dist/mcp-client/app-api.js.map +1 -0
- package/dist/mcp-client/index.d.ts +3 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +3 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/oauth-client.d.ts +113 -0
- package/dist/mcp-client/oauth-client.d.ts.map +1 -0
- package/dist/mcp-client/oauth-client.js +316 -0
- package/dist/mcp-client/oauth-client.js.map +1 -0
- package/dist/mcp-client/oauth-routes.d.ts +25 -0
- package/dist/mcp-client/oauth-routes.d.ts.map +1 -0
- package/dist/mcp-client/oauth-routes.js +218 -0
- package/dist/mcp-client/oauth-routes.js.map +1 -0
- package/dist/mcp-client/remote-store.d.ts +21 -6
- package/dist/mcp-client/remote-store.d.ts.map +1 -1
- package/dist/mcp-client/remote-store.js +72 -124
- package/dist/mcp-client/remote-store.js.map +1 -1
- package/dist/mcp-client/remote-url.d.ts +8 -0
- package/dist/mcp-client/remote-url.d.ts.map +1 -0
- package/dist/mcp-client/remote-url.js +103 -0
- package/dist/mcp-client/remote-url.js.map +1 -0
- package/dist/mcp-client/routes.d.ts +1 -0
- package/dist/mcp-client/routes.d.ts.map +1 -1
- package/dist/mcp-client/routes.js +9 -0
- package/dist/mcp-client/routes.js.map +1 -1
- package/dist/oauth-tokens/store.d.ts +2 -2
- package/dist/oauth-tokens/store.d.ts.map +1 -1
- package/dist/oauth-tokens/store.js +10 -6
- package/dist/oauth-tokens/store.js.map +1 -1
- package/dist/provider-api/index.d.ts +14 -0
- package/dist/provider-api/index.d.ts.map +1 -1
- package/dist/provider-api/index.js +499 -61
- package/dist/provider-api/index.js.map +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
- package/dist/secrets/register-framework-secrets.js +28 -0
- package/dist/secrets/register-framework-secrets.js.map +1 -1
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +2 -0
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +9 -1
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/workspace-provider-oauth.d.ts +10 -3
- package/dist/server/workspace-provider-oauth.d.ts.map +1 -1
- package/dist/server/workspace-provider-oauth.js +325 -43
- package/dist/server/workspace-provider-oauth.js.map +1 -1
- package/dist/templates/default/AGENTS.md +9 -7
- package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
- package/package.json +2 -2
- package/src/templates/default/AGENTS.md +9 -7
- 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
|
|
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",
|