Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,7 @@ export type ConfigEntry =
scope?: string
callback_port?: number
redirect_uri?: string
auth_server_metadata_url?: string
}
| false
disabled?: boolean
Expand Down Expand Up @@ -4667,6 +4668,7 @@ export type McpAddInput = {
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
readonly auth_server_metadata_url?: string
}
| false
readonly disabled?: boolean
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/mcp/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
discoverOAuthServerInfo,
extractWWWAuthenticateParams,
parseErrorResponse,
resourceUrlFromServerUrl,
UnauthorizedError,
type FetchLike,
type OAuthClientProvider,
type OAuthDiscoveryState,
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from "@modelcontextprotocol/client"
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
Expand Down Expand Up @@ -99,6 +101,25 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return request
})

// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this
// state instead of probing the resource server, whose well-known path may not exist.
export const configuredDiscovery = async (input: {
readonly config: typeof ConfigMCP.Remote.Type
readonly fetchFn: FetchLike
}): Promise<OAuthDiscoveryState | undefined> => {
const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined
if (!url) return undefined
const response = await input.fetchFn(url, { headers: { accept: "application/json" } })
if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`)
const body = await response.json()
const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body)
return {
authorizationServerUrl: metadata.issuer,
authorizationServerMetadata: metadata,
resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() },
}
}

export interface Store {
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
Expand Down Expand Up @@ -134,7 +155,10 @@ export const provider = (options: Options): OAuthClientProvider => {
let discovery: OAuthDiscoveryState | undefined = options.discovery
return {
redirectUrl,
discoveryState: () => discovery,
discoveryState: async () => {
discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send })
return discovery
},
saveDiscoveryState: (state) => {
discovery = state
},
Expand Down Expand Up @@ -388,7 +412,9 @@ export const authorize = (input: {
// CIMD needs the server to advertise it and accept public clients, and our published document only
// lists the loopback redirect; a configured client_id always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
try: async () =>
(await configuredDiscovery({ config: input.config, fetchFn })) ??
discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,38 @@ describe("MCP OAuth", () => {
expect(url.pathname).toBe("/as/authorize")
})

test("uses configured authorization server metadata when the resource publishes none", async () => {
const { server: issuer } = authorizationServer({})
const resource = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
const url = `${resource.url.origin}/mcp`
const oauth = {
client_id: "client",
auth_server_metadata_url: `${issuer.url.origin}/.well-known/oauth-authorization-server`,
}

const { url: authorization } = await Effect.runPromise(Effect.scoped(start(url, oauth)))
expect(authorization.origin).toBe(issuer.url.origin)
expect(authorization.pathname).toBe("/authorize")
expect(authorization.searchParams.get("resource")).toBe(url)

const { server, tokenRequests } = authorizationServer({})
const store = memoryCredentials([credential({ access: "expired", refresh: "refresh", url })])
const oauthProvider = await connectProvider(
new ConfigMCP.Remote({
type: "remote",
url,
oauth: { ...oauth, auth_server_metadata_url: `${server.url.origin}/.well-known/oauth-authorization-server` },
}),
store,
)
await auth(oauthProvider, { serverUrl: url }).finally(() => {
resource.stop(true)
issuer.stop(true)
server.stop(true)
})
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
})

test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
Expand Down
6 changes: 6 additions & 0 deletions packages/protocol/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15250,6 +15250,9 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -15549,6 +15552,9 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
4 changes: 4 additions & 0 deletions packages/schema/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
auth_server_metadata_url: Schema.String.pipe(optional).annotate({
description:
"URL of the OAuth or OpenID Connect authorization server metadata document. Set when the MCP server does not publish protected resource metadata that names its authorization server.",
}),
}) {}

export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
Expand Down
6 changes: 6 additions & 0 deletions services/www/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15250,6 +15250,9 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -15549,6 +15552,9 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
6 changes: 6 additions & 0 deletions services/www/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15250,6 +15250,9 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -15549,6 +15552,9 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down
1 change: 1 addition & 0 deletions services/www/src/docs/content/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields:
| `scope` | Space-delimited scopes to request. |
| `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. |
| `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. |
| `auth_server_metadata_url` | URL of the authorization server's OAuth or OpenID Connect metadata document. Set it when the MCP server does not publish protected resource metadata that names its authorization server. |

Remove stored OAuth credentials when you need to sign in again or switch accounts:

Expand Down
Loading