// Selfhost-only: an MCP integration declares NO oauth scopes — the server does. // At connect, `oauth.start` DISCOVERS the request scopes from the MCP server's // RFC 9728 protected-resource metadata and asks for exactly those on the // authorize URL. Proven end-to-end: a real MCP server (in this process) // publishes `scopes_supported`; the selfhost dev server fetches it over loopback // and the returned authorize URL carries the discovered scopes — not a declared // set and not the OAuth app's own scopes (the app declares none). import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, } from "@executor-js/sdk/shared"; import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; const api = composePluginApi([mcpHttpPlugin()] as const); // The scopes the MCP server advertises in its protected-resource metadata. const DISCOVERED_SCOPES = ["channels:history", "users:read"] as const; const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; scenario( "MCP OAuth · oauth.start requests the scopes the server advertises, not a declared set", {}, Effect.scoped( Effect.gen(function* () { const target = yield* Target; const { client: makeApiClient } = yield* Api; // A real authorization server (endpoints for the OAuth app) and a real MCP // server whose RFC 9728 metadata advertises `scopes_supported`. The MCP // integration declares NO template scopes — the server is the source. const oauth = yield* serveOAuthTestServer(); const mcp = yield* serveMcpServer(() => makeGreetingMcpServer(), { auth: { validateAuthorization: () => Effect.succeed(true), authorizationServerUrls: [oauth.issuerUrl], scopes: [...DISCOVERED_SCOPES], }, }); const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); const slug = freshSlug("mcp-scope-discovery"); // An MCP integration with a single OAuth method — no scopes declared. yield* client.mcp.addServer({ payload: { transport: "remote", name: "Scope-discovery MCP", endpoint: mcp.endpoint, slug, authenticationTemplate: [{ kind: "oauth2" }], }, }); yield* Effect.addFinalizer(() => client.mcp .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) .pipe(Effect.ignore), ); // An OAuth app bound to the MCP server as its resource (RFC 8707). The app // carries no scope set of its own. const clientSlug = OAuthClientSlug.make(freshSlug("mcp-scope-app")); yield* client.oauth.createClient({ payload: { owner: "org", slug: clientSlug, authorizationUrl: oauth.authorizationEndpoint, tokenUrl: oauth.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", resource: mcp.endpoint, }, }); // Best-effort cleanup: `removeServer` only reaps DCR-shaped clients, so this // manually-created app must be removed explicitly. yield* Effect.addFinalizer(() => client.oauth .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) .pipe(Effect.ignore), ); const started = yield* client.oauth.start({ payload: { owner: "org", client: clientSlug, clientOwner: "org", name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("oauth2"), }, }); expect(started.status, "start hands back an authorize redirect").toBe("redirect"); if (started.status !== "redirect") return; // The redirect opened an in-flight OAuth session; drop it so the run leaves // no pending session behind. yield* Effect.addFinalizer(() => client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore), ); // The selfhost server fetched the MCP server's protected-resource metadata // and requested exactly the scopes it advertised. expect( scopesFromAuthorizeUrl(started.authorizationUrl), "the authorize URL carries exactly the scopes the MCP server advertised", ).toEqual([...DISCOVERED_SCOPES]); }), ), );