History

Handle manual-auth MCP probes (#910)

Rhys Sullivan committed 5 months ago

Commit c252d4b1 parent 972ad8b5 files · +85 −6Browse files

Unified · Split

5 files
  1. packages/plugins/mcp/src/api/group.ts +1 −0
  2. packages/plugins/mcp/src/react/AddMcpSource.tsx +10 −2
  3. packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx +15 −2
  4. packages/plugins/mcp/src/sdk/plugin.test.ts +42 −1
  5. packages/plugins/mcp/src/sdk/plugin.ts +17 −1
packages/plugins/mcp/src/api/group.ts+1−0
@@ -54,6 +54,7 @@ const ProbeEndpointPayload = Schema.Struct({ · 53 unmodified lines
54 54
55const ProbeEndpointResponse = Schema.Struct({55const ProbeEndpointResponse = Schema.Struct({
56 connected: Schema.Boolean,56 connected: Schema.Boolean,
57 requiresAuthentication: Schema.Boolean,
57 requiresOAuth: Schema.Boolean,58 requiresOAuth: Schema.Boolean,
58 supportsDynamicRegistration: Schema.Boolean,59 supportsDynamicRegistration: Schema.Boolean,
59 name: Schema.String,60 name: Schema.String,
packages/plugins/mcp/src/react/AddMcpSource.tsx+10−2
@@ -83,6 +83,7 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 82 unmodified lines
83 83
84type ProbeResult = {84type ProbeResult = {
85 connected: boolean;85 connected: boolean;
86 requiresAuthentication: boolean;
86 requiresOAuth: boolean;87 requiresOAuth: boolean;
87 supportsDynamicRegistration: boolean;88 supportsDynamicRegistration: boolean;
88 name: string;89 name: string;
@@ -220,7 +221,9 @@ export default function AddMcpSource(props: { · 131 unmodified lines
220 const allowedAuthKinds: readonly AuthTemplateEditorKind[] =221 const allowedAuthKinds: readonly AuthTemplateEditorKind[] =
221 probe?.requiresOAuth && probe.supportsDynamicRegistration222 probe?.requiresOAuth && probe.supportsDynamicRegistration
222 ? ["oauth"]223 ? ["oauth"]
223 : ["none", "apikey", "oauth"];224 : probe?.requiresAuthentication
225 ? ["apikey", "oauth"]
226 : ["none", "apikey", "oauth"];
224 227
225 const remoteIdentity = useIntegrationIdentity({228 const remoteIdentity = useIntegrationIdentity({
226 fallbackName:229 fallbackName:
@@ -270,7 +273,12 @@ export default function AddMcpSource(props: { · 43 unmodified lines
270 setAuthValue(273 setAuthValue(
271 exit.value.requiresOAuth274 exit.value.requiresOAuth
272 ? { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }275 ? { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }
273 : { kind: "none" },276 : exit.value.requiresAuthentication
277 ? {
278 kind: "apikey",
279 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
280 }
281 : { kind: "none" },
274 );282 );
275 dispatch({ type: "probe-ok", probe: exit.value });283 dispatch({ type: "probe-ok", probe: exit.value });
276 }, [state.url, doProbe]);284 }, [state.url, doProbe]);
packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+15−2
@@ -25,6 +25,8 @@ export type McpRemoteSourcePreview = { · 24 unmodified lines
25 readonly name: string;25 readonly name: string;
26 readonly serverName: string | null;26 readonly serverName: string | null;
27 readonly connected: boolean;27 readonly connected: boolean;
28 readonly requiresAuthentication: boolean;
29 readonly requiresOAuth: boolean;
28 readonly toolCount: number | null;30 readonly toolCount: number | null;
29};31};
30 32
@@ -44,7 +46,11 @@ export function McpRemoteSourceFields(props: { · 13 unmodified lines
44 ? props.preview.toolCount === null46 ? props.preview.toolCount === null
45 ? null47 ? null
46 : `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available`48 : `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available`
47 : "OAuth required to discover tools"49 : props.preview.requiresOAuth
50 ? "OAuth required to discover tools"
51 : props.preview.requiresAuthentication
52 ? "Authentication required to discover tools"
53 : "Ready to add"
48 : null;54 : null;
49 55
50 if (props.preview) {56 if (props.preview) {
@@ -71,13 +77,20 @@ export function McpRemoteSourceFields(props: { · 20 unmodified lines
71 >77 >
72 Connected78 Connected
73 </Badge>79 </Badge>
74 ) : (80 ) : props.preview.requiresOAuth ? (
75 <Badge81 <Badge
76 variant="outline"82 variant="outline"
77 className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400"83 className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400"
78 >84 >
79 OAuth required85 OAuth required
80 </Badge>86 </Badge>
87 ) : (
88 <Badge
89 variant="outline"
90 className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400"
91 >
92 Auth required
93 </Badge>
81 )}94 )}
82 </CardStackEntryActions>95 </CardStackEntryActions>
83 </CardStackEntry>96 </CardStackEntry>
packages/plugins/mcp/src/sdk/plugin.test.ts+42−1
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";
3 4
4import {5import {
5 AuthTemplateSlug,6 AuthTemplateSlug,
@@ -8,7 +9,11 @@ import { · 2 unmodified lines
8 ToolAddress,9 ToolAddress,
9 createExecutor,10 createExecutor,
10} from "@executor-js/sdk";11} from "@executor-js/sdk";
11import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";12import {
13 makeTestConfig,
14 memoryCredentialsPlugin,
15 serveTestHttpApp,
16} from "@executor-js/sdk/testing";
12 17
13import { mcpPlugin, userFacingProbeMessage } from "./plugin";18import { mcpPlugin, userFacingProbeMessage } from "./plugin";
14import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";19import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
@@ -245,6 +250,42 @@ describe("mcpPlugin", () => { · 230 unmodified lines
245 yield* Effect.promise(() => config.testDb.close());250 yield* Effect.promise(() => config.testDb.close());
246 }),251 }),
247 );252 );
253
254 it.effect("probeEndpoint returns manual auth when MCP requires auth without OAuth metadata", () =>
255 Effect.scoped(
256 Effect.gen(function* () {
257 const server = yield* serveTestHttpApp((request) =>
258 Effect.succeed(
259 (request.url ?? "").includes("/.well-known/")
260 ? HttpServerResponse.text("missing", { status: 404 })
261 : HttpServerResponse.jsonUnsafe(
262 {
263 jsonrpc: "2.0",
264 id: null,
265 error: { code: -32000, message: "Unauthorized: Valid API key required." },
266 },
267 { status: 401, headers: { "www-authenticate": "Bearer" } },
268 ),
269 ),
270 );
271 const config = makeTestConfig({ plugins: [mcpPlugin()] as const });
272 const executor = yield* createExecutor(config);
273
274 const result = yield* executor.mcp.probeEndpoint(server.url("/mcp"));
275
276 expect(result).toMatchObject({
277 connected: false,
278 requiresAuthentication: true,
279 requiresOAuth: false,
280 supportsDynamicRegistration: false,
281 toolCount: null,
282 });
283
284 yield* executor.close();
285 yield* Effect.promise(() => config.testDb.close());
286 }),
287 ),
288 );
248});289});
249 290
250// ---------------------------------------------------------------------------291// ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/plugin.ts+17−1
@@ -123,6 +123,7 @@ const McpProbeEndpointInputSchema = Schema.Struct({ · 122 unmodified lines
123 123
124const McpProbeEndpointOutputSchema = Schema.Struct({124const McpProbeEndpointOutputSchema = Schema.Struct({
125 connected: Schema.Boolean,125 connected: Schema.Boolean,
126 requiresAuthentication: Schema.Boolean,
126 requiresOAuth: Schema.Boolean,127 requiresOAuth: Schema.Boolean,
127 supportsDynamicRegistration: Schema.Boolean,128 supportsDynamicRegistration: Schema.Boolean,
128 name: Schema.String,129 name: Schema.String,
@@ -544,6 +545,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 415 unmodified lines
544 if (result.ok && result.manifest) {545 if (result.ok && result.manifest) {
545 return {546 return {
546 connected: true,547 connected: true,
548 requiresAuthentication: false,
547 requiresOAuth: false,549 requiresOAuth: false,
548 supportsDynamicRegistration: false,550 supportsDynamicRegistration: false,
549 name: result.manifest.server?.name ?? name,551 name: result.manifest.server?.name ?? name,
@@ -577,6 +579,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 27 unmodified lines
577 if (probeResult.ok) {579 if (probeResult.ok) {
578 return {580 return {
579 connected: false,581 connected: false,
582 requiresAuthentication: true,
580 requiresOAuth: true,583 requiresOAuth: true,
581 supportsDynamicRegistration: probeResult.oauth.registrationEndpoint != null,584 supportsDynamicRegistration: probeResult.oauth.registrationEndpoint != null,
582 name,585 name,
@@ -586,10 +589,23 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 3 unmodified lines
586 } satisfies McpProbeResult;589 } satisfies McpProbeResult;
587 }590 }
588 591
592 if (shape.requiresAuth) {
593 return {
594 connected: false,
595 requiresAuthentication: true,
596 requiresOAuth: false,
597 supportsDynamicRegistration: false,
598 name,
599 slug,
600 toolCount: null,
601 serverName: null,
602 } satisfies McpProbeResult;
603 }
604
589 return yield* new McpConnectionError({605 return yield* new McpConnectionError({
590 transport: "remote",606 transport: "remote",
591 message:607 message:
592 "This server requires authentication, but OAuth metadata wasn't found. Add credentials (Authorization header, query parameter, or API key) below and retry.",608 "This endpoint looks like MCP, but Executor couldn't discover tools from it. Check the URL and try again.",
593 });609 });
594 }).pipe(610 }).pipe(
595 Effect.withSpan("mcp.plugin.probe_endpoint", {611 Effect.withSpan("mcp.plugin.probe_endpoint", {