mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-13 20:24:53 +00:00
feat(mcp): add OAuth authentication support for remote MCP servers (#5014)
This commit is contained in:
@@ -3,12 +3,17 @@ import { experimental_createMCPClient } from "@ai-sdk/mcp"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { McpOAuthProvider } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import open from "open"
|
||||
|
||||
export namespace MCP {
|
||||
const log = Log.create({ service: "mcp" })
|
||||
@@ -46,6 +51,21 @@ export namespace MCP {
|
||||
.meta({
|
||||
ref: "MCPStatusFailed",
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
status: z.literal("needs_auth"),
|
||||
})
|
||||
.meta({
|
||||
ref: "MCPStatusNeedsAuth",
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
status: z.literal("needs_client_registration"),
|
||||
error: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "MCPStatusNeedsClientRegistration",
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
ref: "MCPStatus",
|
||||
@@ -53,6 +73,10 @@ export namespace MCP {
|
||||
export type Status = z.infer<typeof Status>
|
||||
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
|
||||
// Store transports for OAuth servers to allow finishing auth
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
@@ -87,6 +111,7 @@ export namespace MCP {
|
||||
}),
|
||||
),
|
||||
)
|
||||
pendingOAuthTransports.clear()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -120,58 +145,98 @@ export namespace MCP {
|
||||
async function create(key: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
return
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status: { status: "disabled" as const },
|
||||
}
|
||||
}
|
||||
log.info("found", { key, type: mcp.type })
|
||||
let mcpClient: MCPClient | undefined
|
||||
let status: Status | undefined = undefined
|
||||
|
||||
if (mcp.type === "remote") {
|
||||
const transports = [
|
||||
// OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
|
||||
const oauthDisabled = mcp.oauth === false
|
||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
|
||||
if (!oauthDisabled) {
|
||||
authProvider = new McpOAuthProvider(
|
||||
key,
|
||||
mcp.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||
// Store the URL - actual browser opening is handled by startAuth
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
authProvider,
|
||||
requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
authProvider,
|
||||
requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
let lastError: Error | undefined
|
||||
for (const { name, transport } of transports) {
|
||||
const result = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
})
|
||||
.then((client) => {
|
||||
log.info("connected", { key, transport: name })
|
||||
mcpClient = client
|
||||
status = { status: "connected" }
|
||||
return true
|
||||
try {
|
||||
mcpClient = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
})
|
||||
.catch((error) => {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: lastError.message,
|
||||
log.info("connected", { key, transport: name })
|
||||
status = { status: "connected" }
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
// Handle OAuth-specific errors
|
||||
if (error instanceof UnauthorizedError) {
|
||||
log.info("mcp server requires authentication", { key, transport: name })
|
||||
|
||||
// Check if this is a "needs registration" error
|
||||
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
||||
status = {
|
||||
status: "needs_client_registration" as const,
|
||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||
}
|
||||
} else {
|
||||
// Store transport for later finishAuth call
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
status = { status: "needs_auth" as const }
|
||||
}
|
||||
return false
|
||||
break
|
||||
}
|
||||
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
if (result) break
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: lastError.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,4 +351,165 @@ export namespace MCP {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authentication flow for an MCP server.
|
||||
* Returns the authorization URL that should be opened in a browser.
|
||||
*/
|
||||
export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
|
||||
const cfg = await Config.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
|
||||
if (!mcpConfig) {
|
||||
throw new Error(`MCP server not found: ${mcpName}`)
|
||||
}
|
||||
|
||||
if (mcpConfig.type !== "remote") {
|
||||
throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||
}
|
||||
|
||||
if (mcpConfig.oauth === false) {
|
||||
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
}
|
||||
|
||||
// Start the callback server
|
||||
await McpOAuthCallback.ensureRunning()
|
||||
|
||||
// Create a new auth provider for this flow
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
mcpConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
capturedUrl = url
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Create transport with auth provider
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
|
||||
authProvider,
|
||||
})
|
||||
|
||||
// Try to connect - this will trigger the OAuth flow
|
||||
try {
|
||||
await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
})
|
||||
// If we get here, we're already authenticated
|
||||
return { authorizationUrl: "" }
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
// Store transport for finishAuth
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return { authorizationUrl: capturedUrl.toString() }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication after user authorizes in browser.
|
||||
* Opens the browser and waits for callback.
|
||||
*/
|
||||
export async function authenticate(mcpName: string): Promise<Status> {
|
||||
const { authorizationUrl } = await startAuth(mcpName)
|
||||
|
||||
if (!authorizationUrl) {
|
||||
// Already authenticated
|
||||
const s = await state()
|
||||
return s.status[mcpName] ?? { status: "connected" }
|
||||
}
|
||||
|
||||
// Extract state from authorization URL to use as callback key
|
||||
// If no state parameter, use mcpName as fallback
|
||||
const authUrl = new URL(authorizationUrl)
|
||||
const oauthState = authUrl.searchParams.get("state") ?? mcpName
|
||||
|
||||
// Open browser
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
await open(authorizationUrl)
|
||||
|
||||
// Wait for callback using the OAuth state parameter (or mcpName as fallback)
|
||||
const code = await McpOAuthCallback.waitForCallback(oauthState)
|
||||
|
||||
// Finish auth
|
||||
return finishAuth(mcpName, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication with the authorization code.
|
||||
*/
|
||||
export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
|
||||
const transport = pendingOAuthTransports.get(mcpName)
|
||||
|
||||
if (!transport) {
|
||||
throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Call finishAuth on the transport
|
||||
await transport.finishAuth(authorizationCode)
|
||||
|
||||
// Clear the code verifier after successful auth
|
||||
await McpAuth.clearCodeVerifier(mcpName)
|
||||
|
||||
// Now try to reconnect
|
||||
const cfg = await Config.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
|
||||
if (!mcpConfig) {
|
||||
throw new Error(`MCP server not found: ${mcpName}`)
|
||||
}
|
||||
|
||||
// Re-add the MCP server to establish connection
|
||||
pendingOAuthTransports.delete(mcpName)
|
||||
const result = await add(mcpName, mcpConfig)
|
||||
|
||||
const statusRecord = result.status as Record<string, Status>
|
||||
return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
|
||||
} catch (error) {
|
||||
log.error("failed to finish oauth", { mcpName, error })
|
||||
return {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove OAuth credentials for an MCP server.
|
||||
*/
|
||||
export async function removeAuth(mcpName: string): Promise<void> {
|
||||
await McpAuth.remove(mcpName)
|
||||
McpOAuthCallback.cancelPending(mcpName)
|
||||
pendingOAuthTransports.delete(mcpName)
|
||||
log.info("removed oauth credentials", { mcpName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
|
||||
*/
|
||||
export async function supportsOAuth(mcpName: string): Promise<boolean> {
|
||||
const cfg = await Config.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
return mcpConfig?.type === "remote" && mcpConfig.oauth !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an MCP server has stored OAuth tokens.
|
||||
*/
|
||||
export async function hasStoredTokens(mcpName: string): Promise<boolean> {
|
||||
const entry = await McpAuth.get(mcpName)
|
||||
return !!entry?.tokens
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user