|
@@ -3,12 +3,17 @@ import { experimental_createMCPClient } from "@ai-sdk/mcp"
|
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
|
|
+import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
|
|
import { Config } from "../config/config"
|
|
import { Config } from "../config/config"
|
|
|
import { Log } from "../util/log"
|
|
import { Log } from "../util/log"
|
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
|
import z from "zod/v4"
|
|
import z from "zod/v4"
|
|
|
import { Instance } from "../project/instance"
|
|
import { Instance } from "../project/instance"
|
|
|
import { withTimeout } from "@/util/timeout"
|
|
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 {
|
|
export namespace MCP {
|
|
|
const log = Log.create({ service: "mcp" })
|
|
const log = Log.create({ service: "mcp" })
|
|
@@ -46,6 +51,21 @@ export namespace MCP {
|
|
|
.meta({
|
|
.meta({
|
|
|
ref: "MCPStatusFailed",
|
|
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({
|
|
.meta({
|
|
|
ref: "MCPStatus",
|
|
ref: "MCPStatus",
|
|
@@ -53,6 +73,10 @@ export namespace MCP {
|
|
|
export type Status = z.infer<typeof Status>
|
|
export type Status = z.infer<typeof Status>
|
|
|
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
|
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(
|
|
const state = Instance.state(
|
|
|
async () => {
|
|
async () => {
|
|
|
const cfg = await Config.get()
|
|
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) {
|
|
async function create(key: string, mcp: Config.Mcp) {
|
|
|
if (mcp.enabled === false) {
|
|
if (mcp.enabled === false) {
|
|
|
log.info("mcp server disabled", { key })
|
|
log.info("mcp server disabled", { key })
|
|
|
- return
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ mcpClient: undefined,
|
|
|
|
|
+ status: { status: "disabled" as const },
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
log.info("found", { key, type: mcp.type })
|
|
log.info("found", { key, type: mcp.type })
|
|
|
let mcpClient: MCPClient | undefined
|
|
let mcpClient: MCPClient | undefined
|
|
|
let status: Status | undefined = undefined
|
|
let status: Status | undefined = undefined
|
|
|
|
|
|
|
|
if (mcp.type === "remote") {
|
|
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",
|
|
name: "StreamableHTTP",
|
|
|
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
|
- requestInit: {
|
|
|
|
|
- headers: mcp.headers,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ authProvider,
|
|
|
|
|
+ requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
|
|
|
}),
|
|
}),
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
name: "SSE",
|
|
name: "SSE",
|
|
|
transport: new SSEClientTransport(new URL(mcp.url), {
|
|
transport: new SSEClientTransport(new URL(mcp.url), {
|
|
|
- requestInit: {
|
|
|
|
|
- headers: mcp.headers,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ authProvider,
|
|
|
|
|
+ requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
|
|
|
}),
|
|
}),
|
|
|
},
|
|
},
|
|
|
]
|
|
]
|
|
|
|
|
+
|
|
|
let lastError: Error | undefined
|
|
let lastError: Error | undefined
|
|
|
for (const { name, transport } of transports) {
|
|
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
|
|
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
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|