[codex] Fix MCP OAuth and OpenAPI JSON parsing
Unified · Split
| @@ -88,6 +88,7 @@ const ProbeEndpointPayload = Schema.Struct({ · 87 unmodified lines | ||
| 88 | 88 | const ProbeEndpointResponse = Schema.Struct({ |
| 89 | 89 | connected: Schema.Boolean, |
| 90 | 90 | requiresOAuth: Schema.Boolean, |
| 91 | + supportsDynamicRegistration: Schema.Boolean, | |
| 91 | 92 | name: Schema.String, |
| 92 | 93 | namespace: Schema.String, |
| 93 | 94 | toolCount: Schema.NullOr(Schema.Number), |
| @@ -79,6 +79,7 @@ type OAuthTokens = OAuthCompletionPayload; · 78 unmodified lines | ||
| 79 | 79 | type ProbeResult = { |
| 80 | 80 | connected: boolean; |
| 81 | 81 | requiresOAuth: boolean; |
| 82 | + supportsDynamicRegistration: boolean; | |
| 82 | 83 | name: string; |
| 83 | 84 | namespace: string; |
| 84 | 85 | toolCount: number | null; |
| @@ -315,7 +316,7 @@ export default function AddMcpSource(props: { · 230 unmodified lines | ||
| 315 | 316 | const isAdding = state.step === "adding"; |
| 316 | 317 | const isOAuthBusy = |
| 317 | 318 | state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; |
| 318 | − const canUseNone = probe?.requiresOAuth !== true; | |
| 319 | + const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false; | |
| 319 | 320 | const remoteHeadersComplete = remoteHeaders.every( |
| 320 | 321 | (header) => header.name.trim() && header.value.trim(), |
| 321 | 322 | ); |
| @@ -622,7 +623,7 @@ export default function AddMcpSource(props: { · 300 unmodified lines | ||
| 622 | 623 | <FieldLabel>Authentication</FieldLabel> |
| 623 | 624 | <FilterTabs<RemoteAuthMode> |
| 624 | 625 | tabs={ |
| 625 | − probe.requiresOAuth | |
| 626 | + probe.requiresOAuth && probe.supportsDynamicRegistration | |
| 626 | 627 | ? [{ value: "oauth2", label: "OAuth" }] |
| 627 | 628 | : [ |
| 628 | 629 | { value: "none", label: "None" }, |
| @@ -649,16 +650,24 @@ export default function AddMcpSource(props: { · 20 unmodified lines | ||
| 649 | 650 | label="Connect via OAuth" |
| 650 | 651 | help="Start the provider OAuth flow." |
| 651 | 652 | > |
| 652 | − {!tokens && state.step === "probed" && ( | |
| 653 | − <Button | |
| 654 | − type="button" | |
| 655 | − onClick={handleOAuth} | |
| 656 | − variant="outline" | |
| 657 | − className="w-full" | |
| 658 | − > | |
| 659 | − Sign in | |
| 660 | − </Button> | |
| 661 | − )} | |
| 653 | + {!tokens && | |
| 654 | + state.step === "probed" && | |
| 655 | + (probe.supportsDynamicRegistration ? ( | |
| 656 | + <Button | |
| 657 | + type="button" | |
| 658 | + onClick={handleOAuth} | |
| 659 | + variant="outline" | |
| 660 | + className="w-full" | |
| 661 | + > | |
| 662 | + Sign in | |
| 663 | + </Button> | |
| 664 | + ) : ( | |
| 665 | + <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| 666 | + This server requires OAuth, but its authorization server does not support | |
| 667 | + dynamic client registration. Use request headers with a bearer token, or | |
| 668 | + save the source and connect a supported OAuth connection later. | |
| 669 | + </div> | |
| 670 | + ))} | |
| 662 | 671 | |
| 663 | 672 | {!tokens && state.step === "oauth-starting" && ( |
| 664 | 673 | <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"> |
| @@ -122,6 +122,7 @@ export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; · 121 unmodified lines | ||
| 122 | 122 | export interface McpProbeResult { |
| 123 | 123 | readonly connected: boolean; |
| 124 | 124 | readonly requiresOAuth: boolean; |
| 125 | + readonly supportsDynamicRegistration: boolean; | |
| 125 | 126 | readonly name: string; |
| 126 | 127 | readonly namespace: string; |
| 127 | 128 | readonly toolCount: number | null; |
| @@ -1019,6 +1020,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 891 unmodified lines | ||
| 1019 | 1020 | return { |
| 1020 | 1021 | connected: true, |
| 1021 | 1022 | requiresOAuth: false, |
| 1023 | + supportsDynamicRegistration: false, | |
| 1022 | 1024 | name: result.manifest.server?.name ?? name, |
| 1023 | 1025 | namespace, |
| 1024 | 1026 | toolCount: result.manifest.tools.length, |
| @@ -1054,15 +1056,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines | ||
| 1054 | 1056 | queryParams: probeQueryParams, |
| 1055 | 1057 | }) |
| 1056 | 1058 | .pipe( |
| 1057 | − Effect.map(() => true), | |
| 1058 | − Effect.catch(() => Effect.succeed(false)), | |
| 1059 | + Effect.map((oauth) => ({ ok: true as const, oauth })), | |
| 1060 | + Effect.catch(() => Effect.succeed({ ok: false as const, oauth: null })), | |
| 1059 | 1061 | Effect.withSpan("mcp.plugin.probe_oauth"), |
| 1060 | 1062 | ); |
| 1061 | 1063 | |
| 1062 | − if (probeResult) { | |
| 1064 | + if (probeResult.ok) { | |
| 1063 | 1065 | return { |
| 1064 | 1066 | connected: false, |
| 1065 | 1067 | requiresOAuth: true, |
| 1068 | + supportsDynamicRegistration: probeResult.oauth.supportsDynamicRegistration, | |
| 1066 | 1069 | name, |
| 1067 | 1070 | namespace, |
| 1068 | 1071 | toolCount: null, |
| @@ -33,6 +33,20 @@ paths: {} · 32 unmodified lines | ||
| 33 | 33 | }), |
| 34 | 34 | ); |
| 35 | 35 | |
| 36 | + it.effect("falls back to YAML for flow-style YAML documents", () => | |
| 37 | + Effect.gen(function* () { | |
| 38 | + const doc = yield* parse(` | |
| 39 | +{ | |
| 40 | + openapi: 3.0.0, | |
| 41 | + info: { title: Test, version: 1.0.0 }, | |
| 42 | + paths: {} | |
| 43 | +} | |
| 44 | +`); | |
| 45 | + | |
| 46 | + expect(doc.openapi).toBe("3.0.0"); | |
| 47 | + }), | |
| 48 | + ); | |
| 49 | + | |
| 36 | 50 | it.effect("returns a stable parse error for empty documents", () => |
| 37 | 51 | Effect.gen(function* () { |
| 38 | 52 | const error = yield* parse("").pipe(Effect.flip); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 2 | −import { Duration, Effect } from "effect"; | |
| 2 | +import { Duration, Effect, Schema } from "effect"; | |
| 3 | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | 4 | import YAML from "yaml"; |
| 5 | 5 | |
| @@ -107,13 +107,14 @@ const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenAp · 101 unmodified lines | ||
| 107 | 107 | }); |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | − const parsed = yield* Effect.try({ | |
| 111 | − try: () => YAML.parse(trimmed) as unknown, | |
| 112 | − catch: () => | |
| 113 | − new OpenApiParseError({ | |
| 114 | − message: "Failed to parse OpenAPI document", | |
| 115 | − }), | |
| 116 | − }); | |
| 110 | + const parsed = yield* parseJsonLike(trimmed).pipe( | |
| 111 | + Effect.mapError( | |
| 112 | + () => | |
| 113 | + new OpenApiParseError({ | |
| 114 | + message: "Failed to parse OpenAPI document", | |
| 115 | + }), | |
| 116 | + ), | |
| 117 | + ); | |
| 117 | 118 | |
| 118 | 119 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { |
| 119 | 120 | return yield* new OpenApiParseError({ |
| @@ -123,3 +124,14 @@ const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenAp · 3 unmodified lines | ||
| 123 | 124 | |
| 124 | 125 | return parsed as OpenAPI.Document; |
| 125 | 126 | }); |
| 127 | + | |
| 128 | +const parseJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 129 | + | |
| 130 | +const parseJsonLike = (text: string): Effect.Effect<unknown, unknown> => { | |
| 131 | + const parseYaml = Effect.try({ | |
| 132 | + try: () => YAML.parse(text) as unknown, | |
| 133 | + catch: () => "YamlParseFailed" as const, | |
| 134 | + }); | |
| 135 | + if (!text.startsWith("{") && !text.startsWith("[")) return parseYaml; | |
| 136 | + return parseJsonText(text).pipe(Effect.catch(() => parseYaml)); | |
| 137 | +}; | |
| @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines | ||
| 5 | 5 | import * as Exit from "effect/Exit"; |
| 6 | 6 | |
| 7 | 7 | import { cancelOAuth, startOAuth } from "../api/atoms"; |
| 8 | −import { messageFromUnknown, useReportHandledError } from "../api/error-reporting"; | |
| 8 | +import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting"; | |
| 9 | 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 10 | 10 | import { Button } from "../components/button"; |
| 11 | 11 | import { |
| @@ -48,6 +48,7 @@ export type OAuthAuthorizationStartResult = { · 36 unmodified lines | ||
| 48 | 48 | |
| 49 | 49 | class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ |
| 50 | 50 | readonly cause: unknown; |
| 51 | + readonly message: string; | |
| 51 | 52 | }> {} |
| 52 | 53 | |
| 53 | 54 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { |
| @@ -153,11 +154,15 @@ export function useOAuthPopupFlow< · 99 unmodified lines | ||
| 153 | 154 | const startExit = await Effect.runPromiseExit( |
| 154 | 155 | Effect.tryPromise({ |
| 155 | 156 | try: input.run, |
| 156 | − catch: (cause) => new OAuthAuthorizationStartError({ cause }), | |
| 157 | + catch: (cause) => | |
| 158 | + new OAuthAuthorizationStartError({ | |
| 159 | + cause, | |
| 160 | + message: messageFromUnknown(cause, startErrorMessage ?? "Failed to start sign-in"), | |
| 161 | + }), | |
| 157 | 162 | }), |
| 158 | 163 | ); |
| 159 | 164 | if (Exit.isFailure(startExit)) { |
| 160 | − const message = startErrorMessage ?? "Failed to start sign-in"; | |
| 165 | + const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in"); | |
| 161 | 166 | reportHandledError(startExit.cause, { |
| 162 | 167 | surface: "oauth", |
| 163 | 168 | action: "start", |
| @@ -278,7 +283,11 @@ export function useOAuthPopupFlow< · 114 unmodified lines | ||
| 278 | 283 | }).then((exit) => |
| 279 | 284 | Exit.isSuccess(exit) |
| 280 | 285 | ? exit.value |
| 281 | − : Effect.runPromise(Effect.fail(startErrorMessage ?? "Failed to start sign-in")), | |
| 286 | + : Effect.runPromise( | |
| 287 | + Effect.fail({ | |
| 288 | + message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"), | |
| 289 | + }), | |
| 290 | + ), | |
| 282 | 291 | ), |
| 283 | 292 | }); |
| 284 | 293 | }, |