History

Merge pull request #731 from RhysSullivan/codex/fix-mcp-oauth-openapi-parsing

[codex] Fix MCP OAuth and OpenAPI JSON parsing

Rhys Sullivan committed 6 months ago

Commit d35cb4c2 parents f8fc3b4 4e608926 files · +75 −27Browse files

Unified · Split

6 files
  1. packages/plugins/mcp/src/api/group.ts +1 −0
  2. packages/plugins/mcp/src/react/AddMcpSource.tsx +21 −12
  3. packages/plugins/mcp/src/sdk/plugin.ts +6 −3
  4. packages/plugins/openapi/src/sdk/parse.test.ts +14 −0
  5. packages/plugins/openapi/src/sdk/parse.ts +20 −8
  6. packages/react/src/plugins/oauth-sign-in.tsx +13 −4
packages/plugins/mcp/src/api/group.ts+1−0
@@ -88,6 +88,7 @@ const ProbeEndpointPayload = Schema.Struct({ · 87 unmodified lines
8888 const ProbeEndpointResponse = Schema.Struct({
8989 connected: Schema.Boolean,
9090 requiresOAuth: Schema.Boolean,
91+ supportsDynamicRegistration: Schema.Boolean,
9192 name: Schema.String,
9293 namespace: Schema.String,
9394 toolCount: Schema.NullOr(Schema.Number),
packages/plugins/mcp/src/react/AddMcpSource.tsx+21−12
@@ -79,6 +79,7 @@ type OAuthTokens = OAuthCompletionPayload; · 78 unmodified lines
7979 type ProbeResult = {
8080 connected: boolean;
8181 requiresOAuth: boolean;
82+ supportsDynamicRegistration: boolean;
8283 name: string;
8384 namespace: string;
8485 toolCount: number | null;
@@ -315,7 +316,7 @@ export default function AddMcpSource(props: { · 230 unmodified lines
315316 const isAdding = state.step === "adding";
316317 const isOAuthBusy =
317318 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;
319320 const remoteHeadersComplete = remoteHeaders.every(
320321 (header) => header.name.trim() && header.value.trim(),
321322 );
@@ -622,7 +623,7 @@ export default function AddMcpSource(props: { · 300 unmodified lines
622623 <FieldLabel>Authentication</FieldLabel>
623624 <FilterTabs<RemoteAuthMode>
624625 tabs={
625− probe.requiresOAuth
626+ probe.requiresOAuth && probe.supportsDynamicRegistration
626627 ? [{ value: "oauth2", label: "OAuth" }]
627628 : [
628629 { value: "none", label: "None" },
@@ -649,16 +650,24 @@ export default function AddMcpSource(props: { · 20 unmodified lines
649650 label="Connect via OAuth"
650651 help="Start the provider OAuth flow."
651652 >
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+ ))}
662671
663672 {!tokens && state.step === "oauth-starting" && (
664673 <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
packages/plugins/mcp/src/sdk/plugin.ts+6−3
@@ -122,6 +122,7 @@ export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; · 121 unmodified lines
122122 export interface McpProbeResult {
123123 readonly connected: boolean;
124124 readonly requiresOAuth: boolean;
125+ readonly supportsDynamicRegistration: boolean;
125126 readonly name: string;
126127 readonly namespace: string;
127128 readonly toolCount: number | null;
@@ -1019,6 +1020,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 891 unmodified lines
10191020 return {
10201021 connected: true,
10211022 requiresOAuth: false,
1023+ supportsDynamicRegistration: false,
10221024 name: result.manifest.server?.name ?? name,
10231025 namespace,
10241026 toolCount: result.manifest.tools.length,
@@ -1054,15 +1056,16 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines
10541056 queryParams: probeQueryParams,
10551057 })
10561058 .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 })),
10591061 Effect.withSpan("mcp.plugin.probe_oauth"),
10601062 );
10611063
1062− if (probeResult) {
1064+ if (probeResult.ok) {
10631065 return {
10641066 connected: false,
10651067 requiresOAuth: true,
1068+ supportsDynamicRegistration: probeResult.oauth.supportsDynamicRegistration,
10661069 name,
10671070 namespace,
10681071 toolCount: null,
packages/plugins/openapi/src/sdk/parse.test.ts+14−0
@@ -33,6 +33,20 @@ paths: {} · 32 unmodified lines
3333 }),
3434 );
3535
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+
3650 it.effect("returns a stable parse error for empty documents", () =>
3751 Effect.gen(function* () {
3852 const error = yield* parse("").pipe(Effect.flip);
packages/plugins/openapi/src/sdk/parse.ts+20−8
@@ -1,5 +1,5 @@
11 import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
2−import { Duration, Effect } from "effect";
2+import { Duration, Effect, Schema } from "effect";
33 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44 import YAML from "yaml";
55
@@ -107,13 +107,14 @@ const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenAp · 101 unmodified lines
107107 });
108108 }
109109
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+ );
117118
118119 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
119120 return yield* new OpenApiParseError({
@@ -123,3 +124,14 @@ const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenAp · 3 unmodified lines
123124
124125 return parsed as OpenAPI.Document;
125126 });
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+};
packages/react/src/plugins/oauth-sign-in.tsx+13−4
@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines
55 import * as Exit from "effect/Exit";
66
77 import { cancelOAuth, startOAuth } from "../api/atoms";
8−import { messageFromUnknown, useReportHandledError } from "../api/error-reporting";
8+import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";
99 import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
1010 import { Button } from "../components/button";
1111 import {
@@ -48,6 +48,7 @@ export type OAuthAuthorizationStartResult = { · 36 unmodified lines
4848
4949 class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{
5050 readonly cause: unknown;
51+ readonly message: string;
5152 }> {}
5253
5354 export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = {
@@ -153,11 +154,15 @@ export function useOAuthPopupFlow< · 99 unmodified lines
153154 const startExit = await Effect.runPromiseExit(
154155 Effect.tryPromise({
155156 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+ }),
157162 }),
158163 );
159164 if (Exit.isFailure(startExit)) {
160− const message = startErrorMessage ?? "Failed to start sign-in";
165+ const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in");
161166 reportHandledError(startExit.cause, {
162167 surface: "oauth",
163168 action: "start",
@@ -278,7 +283,11 @@ export function useOAuthPopupFlow< · 114 unmodified lines
278283 }).then((exit) =>
279284 Exit.isSuccess(exit)
280285 ? 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+ ),
282291 ),
283292 });
284293 },