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
88const ProbeEndpointResponse = Schema.Struct({88const 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),
packages/plugins/mcp/src/react/AddMcpSource.tsx+21−12
@@ -79,6 +79,7 @@ type OAuthTokens = OAuthCompletionPayload; · 78 unmodified lines
79type ProbeResult = {79type 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.requiresOAuth626 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 <Button654 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 in660 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">
packages/plugins/mcp/src/sdk/plugin.ts+6−3
@@ -122,6 +122,7 @@ export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; · 121 unmodified lines
122export interface McpProbeResult {122export 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,
packages/plugins/openapi/src/sdk/parse.test.ts+14−0
@@ -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);
packages/plugins/openapi/src/sdk/parse.ts+20−8
@@ -1,5 +1,5 @@
1import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";1import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
2import { Duration, Effect } from "effect";2import { Duration, Effect, Schema } from "effect";
3import { HttpClient, HttpClientRequest } from "effect/unstable/http";3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4import YAML from "yaml";4import 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
128const parseJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
129
130const 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
5import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
6 6
7import { cancelOAuth, startOAuth } from "../api/atoms";7import { cancelOAuth, startOAuth } from "../api/atoms";
8import { messageFromUnknown, useReportHandledError } from "../api/error-reporting";8import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";
9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
10import { Button } from "../components/button";10import { Button } from "../components/button";
11import {11import {
@@ -48,6 +48,7 @@ export type OAuthAuthorizationStartResult = { · 36 unmodified lines
48 48
49class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{49class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{
50 readonly cause: unknown;50 readonly cause: unknown;
51 readonly message: string;
51}> {}52}> {}
52 53
53export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = {54export 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.value285 ? 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 },