Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/mcp-oauth-declared-scopes.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 48 additions & 2 deletions packages/plugins/mcp/src/react/auth-method-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
[
Expand Down
34 changes: 27 additions & 7 deletions packages/plugins/mcp/src/react/auth-method-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 —
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
40 changes: 40 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -694,6 +694,7 @@ export const describeMcpAuthMethods = (
// oauth2.
oauth: {
discoveryUrl: config.transport === "remote" ? config.endpoint : undefined,
...(method.scopes !== undefined ? { scopes: method.scopes } : {}),
supportsDynamicRegistration: true,
},
};
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/mcp/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down