mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
feat(core): identify to MCP authorization servers with a client metadata document (#47743)
This commit is contained in:
committed by
GitHub
Unverified
parent
596dca4dee
commit
1d391908f4
@@ -1,6 +1,12 @@
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
auth,
|
||||
discoverOAuthServerInfo,
|
||||
parseErrorResponse,
|
||||
type OAuthClientProvider,
|
||||
type OAuthServerInfo,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
@@ -10,6 +16,12 @@ import { OauthCallbackPage } from "../oauth/page.js"
|
||||
import type { Integration } from "../integration.js"
|
||||
import { ErrorSummary } from "../util/error-summary.js"
|
||||
|
||||
/**
|
||||
* opencode's OAuth Client ID Metadata Document. Authorization servers that support CIMD accept this URL as the
|
||||
* client_id and fetch it to learn our name and redirect URIs, so no per-server dynamic registration is needed.
|
||||
*/
|
||||
export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"
|
||||
|
||||
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
|
||||
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -79,6 +91,10 @@ export interface Options {
|
||||
readonly state?: string
|
||||
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
|
||||
readonly client?: { readonly id: string; readonly secret?: string }
|
||||
/** Use opencode's Client ID Metadata Document as the client_id instead of registering dynamically. */
|
||||
readonly clientMetadataUrl?: string
|
||||
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
|
||||
readonly discovery?: OAuthServerInfo
|
||||
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
|
||||
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
|
||||
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
|
||||
@@ -95,6 +111,8 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
const client = options.client
|
||||
return {
|
||||
redirectUrl: options.redirectUrl,
|
||||
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
|
||||
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
|
||||
clientMetadata: {
|
||||
redirect_uris: [options.redirectUrl],
|
||||
client_name: "opencode",
|
||||
@@ -252,12 +270,32 @@ export const authorize = (input: {
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
|
||||
// Discover the authorization server up front so we can decide how to identify ourselves. CIMD only works
|
||||
// when the server advertises it, accepts public clients (our document declares no client secret), and the
|
||||
// redirect is our own loopback URL (a user-configured redirect_uri is not in the published document).
|
||||
// A configured client_id is pre-registered and always wins.
|
||||
const discovery = yield* Effect.tryPromise({
|
||||
try: () => discoverOAuthServerInfo(input.config.url, { fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
const cimd =
|
||||
!oauth?.client_id &&
|
||||
!oauth?.redirect_uri &&
|
||||
discovery.authorizationServerMetadata?.client_id_metadata_document_supported === true &&
|
||||
(discovery.authorizationServerMetadata.token_endpoint_auth_methods_supported?.includes("none") ?? false)
|
||||
yield* Effect.logInfo("mcp oauth client registration selected", {
|
||||
...fields,
|
||||
registration: oauth?.client_id ? "static" : cimd ? "cimd" : "dcr",
|
||||
})
|
||||
|
||||
let authorizationUrl: URL | undefined
|
||||
const oauthProvider = provider({
|
||||
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
|
||||
scope: oauth?.scope,
|
||||
state,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
|
||||
discovery,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
|
||||
|
||||
@@ -164,4 +164,89 @@ describe("MCP OAuth", () => {
|
||||
test("rejects an invalid redirect URL", async () => {
|
||||
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
|
||||
})
|
||||
|
||||
describe("client registration", () => {
|
||||
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
|
||||
const authorizationServer = (metadata: Record<string, unknown>) => {
|
||||
const registrations: unknown[] = []
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/.well-known/oauth-authorization-server")
|
||||
return Response.json({
|
||||
issuer: url.origin,
|
||||
authorization_endpoint: `${url.origin}/authorize`,
|
||||
token_endpoint: `${url.origin}/token`,
|
||||
registration_endpoint: `${url.origin}/register`,
|
||||
response_types_supported: ["code"],
|
||||
...metadata,
|
||||
})
|
||||
if (request.method === "POST" && url.pathname === "/register") {
|
||||
registrations.push(await request.json())
|
||||
return Response.json({ client_id: "registered", redirect_uris: [] })
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/token") {
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({ access_token: "access", token_type: "Bearer" })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
return { server, registrations, tokenRequests }
|
||||
}
|
||||
|
||||
const start = (server: ReturnType<typeof Bun.serve>, oauth?: ConfigMCP.OAuth) =>
|
||||
Effect.gen(function* () {
|
||||
const authorization = yield* McpOAuth.authorize({
|
||||
name: "test",
|
||||
config: new ConfigMCP.Remote({ type: "remote", url: server.url.href, ...(oauth ? { oauth } : {}) }),
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
})
|
||||
return { authorization, url: new URL(authorization.url) }
|
||||
})
|
||||
|
||||
const cimd = { client_id_metadata_document_supported: true, token_endpoint_auth_methods_supported: ["none"] }
|
||||
|
||||
test("uses the client metadata document when the server supports public CIMD clients", async () => {
|
||||
const { server, registrations, tokenRequests } = authorizationServer(cimd)
|
||||
const credential = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const { authorization, url } = yield* start(server)
|
||||
expect(url.searchParams.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri")!)
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", url.searchParams.get("state")!)
|
||||
yield* Effect.promise(() => fetch(redirect))
|
||||
return yield* authorization.callback
|
||||
}),
|
||||
),
|
||||
).finally(() => server.stop(true))
|
||||
|
||||
expect(registrations).toHaveLength(0)
|
||||
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
|
||||
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
|
||||
})
|
||||
|
||||
test("registers dynamically when the server does not accept public clients", async () => {
|
||||
const { server, registrations } = authorizationServer({
|
||||
client_id_metadata_document_supported: true,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
})
|
||||
const { url } = await Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))
|
||||
expect(url.searchParams.get("client_id")).toBe("registered")
|
||||
expect(registrations).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("registers dynamically when a custom redirect_uri is configured", async () => {
|
||||
const { server, registrations } = authorizationServer(cimd)
|
||||
const { url } = await Effect.runPromise(
|
||||
Effect.scoped(start(server, { redirect_uri: "http://127.0.0.1:0/custom" })),
|
||||
).finally(() => server.stop(true))
|
||||
expect(url.searchParams.get("client_id")).toBe("registered")
|
||||
expect(registrations).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user