[codex] Fix MCP OAuth and OpenAPI JSON parsing
Unified · Split
| @@ -88,6 +88,7 @@ const ProbeEndpointPayload = Schema.Struct({ · 87 unmodified lines | |||
| 88 | const ProbeEndpointResponse = Schema.Struct({ | 88 | const ProbeEndpointResponse = Schema.Struct({ |
| 89 | connected: Schema.Boolean, | 89 | connected: Schema.Boolean, |
| 90 | requiresOAuth: Schema.Boolean, | 90 | requiresOAuth: Schema.Boolean, |
| 91 | supportsDynamicRegistration: Schema.Boolean, | ||
| 91 | name: Schema.String, | 92 | name: Schema.String, |
| 92 | namespace: Schema.String, | 93 | namespace: Schema.String, |
| 93 | toolCount: Schema.NullOr(Schema.Number), | 94 | toolCount: Schema.NullOr(Schema.Number), |
| @@ -79,6 +79,7 @@ type OAuthTokens = OAuthCompletionPayload; · 78 unmodified lines | |||
| 79 | type ProbeResult = { | 79 | type ProbeResult = { |
| 80 | connected: boolean; | 80 | connected: boolean; |
| 81 | requiresOAuth: boolean; | 81 | requiresOAuth: boolean; |
| 82 | supportsDynamicRegistration: boolean; | ||
| 82 | name: string; | 83 | name: string; |
| 83 | namespace: string; | 84 | namespace: string; |
| 84 | toolCount: number | null; | 85 | toolCount: number | null; |
| @@ -315,7 +316,7 @@ export default function AddMcpSource(props: { · 230 unmodified lines | |||
| 315 | const isAdding = state.step === "adding"; | 316 | const isAdding = state.step === "adding"; |
| 316 | const isOAuthBusy = | 317 | const isOAuthBusy = |
| 317 | state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; | 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 | const remoteHeadersComplete = remoteHeaders.every( | 320 | const remoteHeadersComplete = remoteHeaders.every( |
| 320 | (header) => header.name.trim() && header.value.trim(), | 321 | (header) => header.name.trim() && header.value.trim(), |
| 321 | ); | 322 | ); |
| @@ -622,7 +623,7 @@ export default function AddMcpSource(props: { · 300 unmodified lines | |||
| 622 | <FieldLabel>Authentication</FieldLabel> | 623 | <FieldLabel>Authentication</FieldLabel> |
| 623 | <FilterTabs<RemoteAuthMode> | 624 | <FilterTabs<RemoteAuthMode> |
| 624 | tabs={ | 625 | tabs={ |
| 625 | probe.requiresOAuth | 626 | probe.requiresOAuth && probe.supportsDynamicRegistration |
| 626 | ? [{ value: "oauth2", label: "OAuth" }] | 627 | ? [{ value: "oauth2", label: "OAuth" }] |
| 627 | : [ | 628 | : [ |
| 628 | { value: "none", label: "None" }, | 629 | { value: "none", label: "None" }, |
| @@ -649,16 +650,24 @@ export default function AddMcpSource(props: { · 20 unmodified lines | |||
| 649 | label="Connect via OAuth" | 650 | label="Connect via OAuth" |
| 650 | help="Start the provider OAuth flow." | 651 | help="Start the provider OAuth flow." |
| 651 | > | 652 | > |
| 652 | {!tokens && state.step === "probed" && ( | 653 | {!tokens && |
| 653 | <Button | 654 | state.step === "probed" && |
| 654 | type="button" | 655 | (probe.supportsDynamicRegistration ? ( |
| 655 | onClick={handleOAuth} | 656 | <Button |
| 656 | variant="outline" | 657 | type="button" |
| 657 | className="w-full" | 658 | onClick={handleOAuth} |
| 658 | > | 659 | variant="outline" |
| 659 | Sign in | 660 | className="w-full" |
| 660 | </Button> | 661 | > |
| 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 | {!tokens && state.step === "oauth-starting" && ( | 672 | {!tokens && state.step === "oauth-starting" && ( |
| 664 | <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"> | 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 | export interface McpProbeResult { | 122 | export interface McpProbeResult { |
| 123 | readonly connected: boolean; | 123 | readonly connected: boolean; |
| 124 | readonly requiresOAuth: boolean; | 124 | readonly requiresOAuth: boolean; |
| 125 | readonly supportsDynamicRegistration: boolean; | ||
| 125 | readonly name: string; | 126 | readonly name: string; |
| 126 | readonly namespace: string; | 127 | readonly namespace: string; |
| 127 | readonly toolCount: number | null; | 128 | readonly toolCount: number | null; |
| @@ -1019,6 +1020,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 891 unmodified lines | |||
| 1019 | return { | 1020 | return { |
| 1020 | connected: true, | 1021 | connected: true, |
| 1021 | requiresOAuth: false, | 1022 | requiresOAuth: false, |
| 1023 | supportsDynamicRegistration: false, | ||
| 1022 | name: result.manifest.server?.name ?? name, | 1024 | name: result.manifest.server?.name ?? name, |
| 1023 | namespace, | 1025 | namespace, |
| 1024 | toolCount: result.manifest.tools.length, | 1026 | toolCount: result.manifest.tools.length, |
| @@ -1054,15 +1056,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines | |||
| 1054 | queryParams: probeQueryParams, | 1056 | queryParams: probeQueryParams, |
| 1055 | }) | 1057 | }) |
| 1056 | .pipe( | 1058 | .pipe( |
| 1057 | Effect.map(() => true), | 1059 | Effect.map((oauth) => ({ ok: true as const, oauth })), |
| 1058 | Effect.catch(() => Effect.succeed(false)), | 1060 | Effect.catch(() => Effect.succeed({ ok: false as const, oauth: null })), |
| 1059 | Effect.withSpan("mcp.plugin.probe_oauth"), | 1061 | Effect.withSpan("mcp.plugin.probe_oauth"), |
| 1060 | ); | 1062 | ); |
| 1061 | 1063 | ||
| 1062 | if (probeResult) { | 1064 | if (probeResult.ok) { |
| 1063 | return { | 1065 | return { |
| 1064 | connected: false, | 1066 | connected: false, |
| 1065 | requiresOAuth: true, | 1067 | requiresOAuth: true, |
| 1068 | supportsDynamicRegistration: probeResult.oauth.supportsDynamicRegistration, | ||
| 1066 | name, | 1069 | name, |
| 1067 | namespace, | 1070 | namespace, |
| 1068 | toolCount: null, | 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 | it.effect("returns a stable parse error for empty documents", () => | 50 | it.effect("returns a stable parse error for empty documents", () => |
| 37 | Effect.gen(function* () { | 51 | Effect.gen(function* () { |
| 38 | const error = yield* parse("").pipe(Effect.flip); | 52 | const error = yield* parse("").pipe(Effect.flip); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; | 1 | import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 2 | import { Duration, Effect } from "effect"; | 2 | import { Duration, Effect, Schema } from "effect"; |
| 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | import YAML from "yaml"; | 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({ | 110 | const parsed = yield* parseJsonLike(trimmed).pipe( |
| 111 | try: () => YAML.parse(trimmed) as unknown, | 111 | Effect.mapError( |
| 112 | catch: () => | 112 | () => |
| 113 | new OpenApiParseError({ | 113 | new OpenApiParseError({ |
| 114 | message: "Failed to parse OpenAPI document", | 114 | message: "Failed to parse OpenAPI document", |
| 115 | }), | 115 | }), |
| 116 | }); | 116 | ), |
| 117 | ); | ||
| 117 | 118 | ||
| 118 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { | 119 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { |
| 119 | return yield* new OpenApiParseError({ | 120 | return yield* new OpenApiParseError({ |
| @@ -123,3 +124,14 @@ const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenAp · 3 unmodified lines | |||
| 123 | 124 | ||
| 124 | return parsed as OpenAPI.Document; | 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 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 6 | 6 | ||
| 7 | import { cancelOAuth, startOAuth } from "../api/atoms"; | 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 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; | 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 10 | import { Button } from "../components/button"; | 10 | import { Button } from "../components/button"; |
| 11 | import { | 11 | import { |
| @@ -48,6 +48,7 @@ export type OAuthAuthorizationStartResult = { · 36 unmodified lines | |||
| 48 | 48 | ||
| 49 | class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ | 49 | class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ |
| 50 | readonly cause: unknown; | 50 | readonly cause: unknown; |
| 51 | readonly message: string; | ||
| 51 | }> {} | 52 | }> {} |
| 52 | 53 | ||
| 53 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { | 54 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { |
| @@ -153,11 +154,15 @@ export function useOAuthPopupFlow< · 99 unmodified lines | |||
| 153 | const startExit = await Effect.runPromiseExit( | 154 | const startExit = await Effect.runPromiseExit( |
| 154 | Effect.tryPromise({ | 155 | Effect.tryPromise({ |
| 155 | try: input.run, | 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 | if (Exit.isFailure(startExit)) { | 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 | reportHandledError(startExit.cause, { | 166 | reportHandledError(startExit.cause, { |
| 162 | surface: "oauth", | 167 | surface: "oauth", |
| 163 | action: "start", | 168 | action: "start", |
| @@ -278,7 +283,11 @@ export function useOAuthPopupFlow< · 114 unmodified lines | |||
| 278 | }).then((exit) => | 283 | }).then((exit) => |
| 279 | Exit.isSuccess(exit) | 284 | Exit.isSuccess(exit) |
| 280 | ? exit.value | 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 | }, |