diff --git a/.changeset/mcp-oauth-declared-scopes.md b/.changeset/mcp-oauth-declared-scopes.md new file mode 100644 index 0000000000..9cd63bac71 --- /dev/null +++ b/.changeset/mcp-oauth-declared-scopes.md @@ -0,0 +1,11 @@ +--- +"executor": patch +--- + +**Fix: allow MCP integrations to declare OAuth scopes when resource metadata omits them** + +MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes +take precedence over protected-resource scope discovery, so servers with fixed +scopes can connect even when their dynamically registered OAuth client has no +resource identifier. Existing integrations without declared scopes keep +discovering them from the server at connect time. diff --git a/packages/plugins/mcp/src/react/auth-method-config.test.ts b/packages/plugins/mcp/src/react/auth-method-config.test.ts index b6a8556e5f..8b4374b089 100644 --- a/packages/plugins/mcp/src/react/auth-method-config.test.ts +++ b/packages/plugins/mcp/src/react/auth-method-config.test.ts @@ -13,14 +13,28 @@ describe("mcpAuthMethodInputFromEditorValue", () => { expect(mcpAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" }); }); - it("maps 'oauth' → { kind: 'oauth2' } (endpoints/scopes are resolved at connect time)", () => { + it("maps declared oauth scopes while provider endpoints remain discovered", () => { const value: AuthTemplateEditorValue = { kind: "oauth", authorizationUrl: "https://a.example.com/auth", tokenUrl: "https://a.example.com/token", scopes: ["mcp.read"], }; - expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" }); + expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ + kind: "oauth2", + scopes: ["mcp.read"], + }); + }); + + it("omits an empty oauth scope list so the server metadata remains authoritative", () => { + expect( + mcpAuthMethodInputFromEditorValue({ + kind: "oauth", + authorizationUrl: "", + tokenUrl: "", + scopes: [], + }), + ).toEqual({ kind: "oauth2" }); }); it("maps a header placement to an apikey method (prefix preserved)", () => { @@ -121,6 +135,25 @@ describe("editorValueFromMcpAuthMethod", () => { scopes: [], }); }); + + it("round-trips declared oauth2 scopes while endpoints remain discovered", () => { + const editor = editorValueFromMcpAuthMethod({ + slug: "oauth2", + kind: "oauth2", + scopes: ["mcp"], + }); + + expect(editor).toEqual({ + kind: "oauth", + authorizationUrl: "", + tokenUrl: "", + scopes: ["mcp"], + }); + expect(mcpAuthMethodInputFromEditorValue(editor)).toEqual({ + kind: "oauth2", + scopes: ["mcp"], + }); + }); }); describe("authMethodsFromConfig", () => { @@ -154,6 +187,19 @@ describe("authMethodsFromConfig", () => { expect(methods[0]?.oauth?.scopes).toBeUndefined(); }); + it("carries declared oauth2 scopes through to the accounts hub", () => { + const methods = authMethodsFromConfig( + [{ slug: "oauth2", kind: "oauth2", scopes: ["mcp"] }], + "https://mcp.example.com/mcp", + ); + + expect(methods[0]?.oauth).toEqual({ + discoveryUrl: "https://mcp.example.com/mcp", + scopes: ["mcp"], + supportsDynamicRegistration: true, + }); + }); + it("carries multi-placement methods through to the hub", () => { const methods = authMethodsFromConfig( [ diff --git a/packages/plugins/mcp/src/react/auth-method-config.ts b/packages/plugins/mcp/src/react/auth-method-config.ts index ae09ba49f6..0675967a23 100644 --- a/packages/plugins/mcp/src/react/auth-method-config.ts +++ b/packages/plugins/mcp/src/react/auth-method-config.ts @@ -2,8 +2,9 @@ // MCP ↔ generic auth-method converters — a thin oauth adapter over the shared // codec (`@executor-js/react/lib/shared-auth-method-codec`). The apikey/none // paths (multi-placement, multi-variable) live in the shared codec; MCP only -// contributes its oauth flavor: endpoint-less methods whose metadata is -// discovered at connect time (`discoveryUrl` = the MCP endpoint). +// contributes its oauth flavor: endpoint-less methods whose provider metadata +// is discovered at connect time (`discoveryUrl` = the MCP endpoint), with an +// optional declared scope override for servers whose metadata omits scopes. // --------------------------------------------------------------------------- import { AuthTemplateSlug } from "@executor-js/sdk/shared"; @@ -48,14 +49,22 @@ export const mcpWireAuthInput = ( method: McpAuthMethod | McpCanonicalAuthMethodInput, ): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput; -const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ +const oauthAuthMethod = ( + slug: string, + endpoint: string, + scopes: readonly string[] | undefined, +): AuthMethod => ({ id: slug, label: "OAuth", kind: "oauth", source: slug.startsWith("custom_") ? "custom" : "spec", template: AuthTemplateSlug.make(slug), placements: [], - oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, + oauth: { + discoveryUrl: endpoint, + ...(scopes !== undefined ? { scopes } : {}), + supportsDynamicRegistration: true, + }, }); /** Convert a generic editor value into one MCP auth-method input (no slug — @@ -65,7 +74,13 @@ const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ export function mcpAuthMethodInputFromEditorValue( value: AuthTemplateEditorValue, ): McpCanonicalAuthMethodInput { - if (value.kind === "oauth") return { kind: "oauth2" }; + if (value.kind === "oauth") { + const [firstScope, ...remainingScopes] = value.scopes; + return { + kind: "oauth2", + ...(firstScope !== undefined ? { scopes: [firstScope, ...remainingScopes] } : {}), + }; + } return (sharedMethodInputFromEditorValue(value) ?? { kind: "none", }) as McpCanonicalAuthMethodInput; @@ -74,7 +89,12 @@ export function mcpAuthMethodInputFromEditorValue( /** Convert one stored MCP method into the generic editor value. */ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue { if (method.kind === "oauth2") { - return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; + return { + kind: "oauth", + authorizationUrl: "", + tokenUrl: "", + scopes: method.scopes ?? [], + }; } if (method.kind === "stdio_env") return stdioEnvEditorValue(method); return editorValueFromSharedMethod(method); @@ -89,7 +109,7 @@ export function authMethodsFromConfig( endpoint: string, ): AuthMethod[] { return methods.map((method: McpAuthMethod): AuthMethod => { - if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); + if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint, method.scopes); if (method.kind === "stdio_env") return stdioEnvAuthMethod(method); return authMethodFromSharedTemplate(method); }); diff --git a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts index e8a9fe5a9f..eb3c896b2f 100644 --- a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts @@ -47,6 +47,30 @@ describe("describeMcpAuthMethods", () => { ]); }); + it("projects declared oauth2 scopes alongside the discovery URL", () => { + const methods = describeMcpAuthMethods( + recordWith({ + transport: "remote", + endpoint: "https://x.example/oauth/mcp", + authenticationTemplate: [{ slug: "oauth2", kind: "oauth2", scopes: ["mcp"] }], + }), + ); + + expect(methods).toEqual([ + { + id: "oauth2", + label: "OAuth", + kind: "oauth", + template: "oauth2", + oauth: { + discoveryUrl: "https://x.example/oauth/mcp", + scopes: ["mcp"], + supportsDynamicRegistration: true, + }, + }, + ]); + }); + it("projects an apikey header method carrying the placement", () => { const methods = describeMcpAuthMethods( recordWith({ diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 0b8338684b..1d4a658a06 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -557,6 +557,46 @@ describe("mcpPlugin", () => { ), ); + it.effect("oauth.start uses declared MCP scopes when the client has no resource", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["mcp"] }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), + ); + + yield* executor.mcp.addServer({ + name: "GitLab MCP", + endpoint: server.mcpResourceUrl, + slug: "gitlab_mcp", + authenticationTemplate: [{ kind: "oauth2", scopes: ["mcp"] }], + }); + yield* executor.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("gitlab-app"), + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: OAuthClientSlug.make("gitlab-app"), + clientOwner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("gitlab_mcp"), + template: AuthTemplateSlug.make("oauth2"), + }); + + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["mcp"]); + }), + ), + ); + // When discovery fails (auth, network, etc.) the connection still lands with // an empty tool set so the user can retry via `connections.refresh` once they // fix the underlying problem. diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857e..3360067a99 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -653,9 +653,9 @@ const connectionPoolKey = ( // stdio → [] (no remote connection to configure) // apikey → carried placements (headers / query params) verbatim // oauth2 → an oauth method carrying the MCP endpoint to probe -// (`discoveryUrl`). Endpoints/scopes are discovered -// live at connect time, so they are NOT pre-resolved -// here. We mark +// (`discoveryUrl`). Endpoints are discovered live at +// connect time. Scopes are discovered too unless the +// method declares them explicitly. We mark // `supportsDynamicRegistration: true` because MCP // OAuth servers are expected to support RFC 7591 DCR; // the connect flow probes to confirm and falls back. @@ -694,6 +694,7 @@ export const describeMcpAuthMethods = ( // oauth2. oauth: { discoveryUrl: config.transport === "remote" ? config.endpoint : undefined, + ...(method.scopes !== undefined ? { scopes: method.scopes } : {}), supportsDynamicRegistration: true, }, }; diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 838e81e0ac..c53995e9b3 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -46,11 +46,15 @@ export type McpTransport = typeof McpTransport.Type; // oauth2 — the value is an OAuth access token, applied as a Bearer header // via the MCP SDK's OAuthClientProvider. MCP oauth carries no // stored endpoints: metadata is discovered live at connect time. +// A non-empty scope list may be declared when a server does not +// expose scopes through protected-resource metadata; otherwise they +// are discovered too. // --------------------------------------------------------------------------- export const McpOAuthMethod = Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2"), + scopes: Schema.optional(Schema.NonEmptyArray(Schema.String)), }); export type McpOAuthMethod = typeof McpOAuthMethod.Type; @@ -114,7 +118,11 @@ export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMetho * `normalizeMcpAuthMethods` backfills it. */ export const McpAuthMethodInput = Schema.Union([ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), - Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }), + Schema.Struct({ + slug: Schema.optional(Schema.String), + kind: Schema.Literal("oauth2"), + scopes: Schema.optional(Schema.NonEmptyArray(Schema.String)), + }), // Credential methods are authored request-shaped — the ONE apikey input // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", // variable("token")] }, queryParams: { … } }`. Stored configs and the