feat(core): add mcp support (#34513)
This commit is contained in:
parent
12887e572e
commit
b1ca070b3b
30 changed files with 1966 additions and 388 deletions
289
packages/core/src/mcp/client.ts
Normal file
289
packages/core/src/mcp/client.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
export * as MCPClient from "./client"
|
||||
|
||||
import path from "node:path"
|
||||
import { execFile } from "node:child_process"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_REQUEST_TIMEOUT = 30_000
|
||||
|
||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport
|
||||
|
||||
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
|
||||
// only that field so a single bad schema doesn't blank out the whole tool list.
|
||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
|
||||
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||
server: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
|
||||
server: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface ToolDefinition {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly inputSchema: unknown
|
||||
}
|
||||
|
||||
export type CallToolContent =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||
|
||||
export interface CallToolResult {
|
||||
readonly isError: boolean
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<CallToolContent>
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
readonly level: LoggingMessageNotification["params"]["level"]
|
||||
readonly logger?: LoggingMessageNotification["params"]["logger"]
|
||||
readonly data: LoggingMessageNotification["params"]["data"]
|
||||
}
|
||||
|
||||
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
|
||||
export interface Connection {
|
||||
/** Server-supplied usage instructions from the initialize result, if any. */
|
||||
readonly instructions: string | undefined
|
||||
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
|
||||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
||||
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
readonly onLog: (callback: (message: LogMessage) => void) => void
|
||||
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
}
|
||||
|
||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
||||
export const connect = Effect.fnUntraced(function* (
|
||||
server: string,
|
||||
config: typeof ConfigMCP.Server.Type,
|
||||
directory: string,
|
||||
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
|
||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||
authProvider?: OAuthClientProvider,
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
const [command, ...args] = config.command
|
||||
return new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...config.environment,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
})
|
||||
})
|
||||
const client = new Client(
|
||||
{ name: "opencode", version: InstallationVersion },
|
||||
{
|
||||
capabilities: {
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
|
||||
const exit = yield* Effect.tryPromise({
|
||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => client.close())),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
|
||||
return {
|
||||
instructions: client.getInstructions()?.trim() || undefined,
|
||||
tools: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!client.getServerCapabilities()?.tools) return []
|
||||
const tools = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
try {
|
||||
return await client.listTools(params, { timeout: requestTimeout })
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
|
||||
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
|
||||
timeout: requestTimeout,
|
||||
})
|
||||
}
|
||||
},
|
||||
(result) => result.tools,
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
|
||||
)
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
}),
|
||||
callTool: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
CallToolResultSchema,
|
||||
// The SDK only sends a progress token when onprogress is present, which enables timeout resets.
|
||||
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
isError: result.isError === true,
|
||||
structured: result.structuredContent,
|
||||
content: result.content.flatMap((part): CallToolContent[] => {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "image" || part.type === "audio")
|
||||
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
|
||||
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
|
||||
if (part.type === "resource") {
|
||||
const resource = part.resource
|
||||
if ("text" in resource && typeof resource.text === "string")
|
||||
return [{ type: "text", text: resource.text }]
|
||||
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
|
||||
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
|
||||
return [{ type: "text", text: resource.uri }]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
})),
|
||||
),
|
||||
onClose: (callback) => {
|
||||
client.onclose = callback
|
||||
},
|
||||
onLog: (callback) => {
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
||||
},
|
||||
onToolsChanged: (callback) => {
|
||||
if (!client.getServerCapabilities()?.tools?.listChanged) return
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
|
||||
},
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
yield* cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => transport.close())),
|
||||
Effect.ignore,
|
||||
)
|
||||
const error = Cause.squash(exit.cause)
|
||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
|
||||
// SDK close stops the MCP process, but not child processes it spawned.
|
||||
const cleanupStdioDescendants = (transport: Transport) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(transport instanceof StdioClientTransport)) return
|
||||
const pid = transport.pid
|
||||
if (typeof pid !== "number") return
|
||||
yield* Effect.forEach(
|
||||
yield* descendantPids(pid),
|
||||
(pid) =>
|
||||
Effect.try({
|
||||
try: () => process.kill(pid, "SIGTERM"),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const descendantPids = Effect.fnUntraced(function* (root: number) {
|
||||
if (process.platform === "win32") return []
|
||||
const result: number[] = []
|
||||
const queue = [root]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const parent = queue[index]
|
||||
if (parent === undefined) return result
|
||||
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
|
||||
result.push(...children)
|
||||
queue.push(...children)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const childPids = (pid: number) =>
|
||||
Effect.promise(
|
||||
() =>
|
||||
new Promise<number[]>((resolve) => {
|
||||
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
|
||||
resolve(
|
||||
stdout
|
||||
.split("\n")
|
||||
.map((line) => Number.parseInt(line, 10))
|
||||
.filter((pid) => Number.isInteger(pid)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
async function paginate<R extends { nextCursor?: string }, T>(
|
||||
list: (cursor: string | undefined) => Promise<R>,
|
||||
items: (result: R) => T[],
|
||||
) {
|
||||
const collected: T[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
while (true) {
|
||||
const result = await list(cursor)
|
||||
collected.push(...items(result))
|
||||
if (result.nextCursor === undefined) return collected
|
||||
// A repeating cursor never terminates; bail instead of hanging the connection forever.
|
||||
if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`)
|
||||
seen.add(result.nextCursor)
|
||||
cursor = result.nextCursor
|
||||
}
|
||||
}
|
||||
|
||||
const isOutputSchemaError = (error: Error) =>
|
||||
/can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
||||
error.message,
|
||||
)
|
||||
78
packages/core/src/mcp/guidance.ts
Normal file
78
packages/core/src/mcp/guidance.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
export * as McpGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { McpTool } from "../tool/mcp"
|
||||
import { MCP } from "./index"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
server: Schema.String,
|
||||
instructions: Schema.String,
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
...servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
].join("\n")
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return SystemContext.empty
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
// Hide a server only when every tool it contributes is wholly denied for this agent.
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return (
|
||||
owned.length === 0 ||
|
||||
owned.some(
|
||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
if (visible.length === 0) return SystemContext.empty
|
||||
return SystemContext.make({
|
||||
key: SystemContext.Key.make("core/mcp-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
||||
498
packages/core/src/mcp/index.ts
Normal file
498
packages/core/src/mcp/index.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
export * as MCP from "./index"
|
||||
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Config } from "../config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Integration } from "../integration"
|
||||
import { IntegrationConnection } from "../integration/connection"
|
||||
import { Location } from "../location"
|
||||
import { MCPClient } from "./client"
|
||||
import { MCPOAuth } from "./oauth"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export type ServerName = typeof ServerName.Type
|
||||
|
||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
||||
export const Status = Mcp.Status
|
||||
export type Status = Mcp.Status
|
||||
|
||||
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
|
||||
name: ServerName,
|
||||
status: Status,
|
||||
integrationID: Integration.ID.pipe(Schema.optional),
|
||||
connection: IntegrationConnection.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
|
||||
server: ServerName,
|
||||
instructions: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ToolResultContent = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ToolResultContent = typeof ToolResultContent.Type
|
||||
|
||||
export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({
|
||||
server: ServerName,
|
||||
tool: Schema.String,
|
||||
isError: Schema.Boolean,
|
||||
structured: Schema.Unknown.pipe(Schema.optional),
|
||||
content: Schema.Array(ToolResultContent),
|
||||
}) {}
|
||||
|
||||
export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
required: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
arguments: Schema.Array(PromptArgument).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({
|
||||
role: Schema.String,
|
||||
content: Schema.Unknown,
|
||||
}) {}
|
||||
|
||||
export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
messages: Schema.Array(PromptMessage),
|
||||
}) {}
|
||||
|
||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uriTemplate: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
|
||||
resources: Schema.Array(Resource),
|
||||
templates: Schema.Array(ResourceTemplate),
|
||||
}) {}
|
||||
|
||||
export const ResourceContentPart = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
uri: Schema.String,
|
||||
text: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("blob"),
|
||||
uri: Schema.String,
|
||||
blob: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ResourceContentPart = typeof ResourceContentPart.Type
|
||||
|
||||
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
|
||||
server: ServerName,
|
||||
uri: Schema.String,
|
||||
contents: Schema.Array(ResourceContentPart),
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||
server: ServerName,
|
||||
}) {}
|
||||
|
||||
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
|
||||
server: ServerName,
|
||||
tool: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
type ServerEntry = {
|
||||
readonly config: typeof ConfigMCP.Server.Type
|
||||
status: Status
|
||||
readonly startup: Deferred.Deferred<void>
|
||||
scope?: Scope.Closeable
|
||||
client?: MCPClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||
integrationID?: Integration.ID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly tools: () => Effect.Effect<Tool[]>
|
||||
readonly callTool: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
readonly prompt: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, string>
|
||||
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
|
||||
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
|
||||
readonly readResource: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly uri: string
|
||||
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const root = yield* Scope.make()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
runtime.set(ServerName.make(name), {
|
||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
||||
status: { status: "disconnected" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||
const registrations: Array<{
|
||||
readonly name: ServerName
|
||||
readonly remote: typeof ConfigMCP.Remote.Type
|
||||
readonly integrationID: Integration.ID
|
||||
readonly methodID: Integration.MethodID
|
||||
}> = []
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
|
||||
const remote = entry.config
|
||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||
const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
|
||||
entry.integrationID = Integration.ID.make(suffix)
|
||||
registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
|
||||
}
|
||||
if (registrations.length > 0)
|
||||
yield* integration.transform((draft) => {
|
||||
for (const reg of registrations) {
|
||||
draft.update(reg.integrationID, (ref) => {
|
||||
ref.name = reg.name
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: reg.integrationID,
|
||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const name = ServerName.make(server)
|
||||
const entry = runtime.get(name)
|
||||
if (!entry) return yield* new NotFoundError({ server: name })
|
||||
return { name, entry }
|
||||
})
|
||||
|
||||
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
|
||||
new ServerInfo({
|
||||
name,
|
||||
status: entry.status,
|
||||
integrationID: entry.integrationID,
|
||||
connection,
|
||||
})
|
||||
|
||||
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
|
||||
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
|
||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const base = {
|
||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
||||
scope: oauth?.scope,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
}
|
||||
const stored = yield* credentials.list(entry.integrationID)
|
||||
const found = stored.find((credential) => credential.value.type === "oauth")
|
||||
if (!found || found.value.type !== "oauth")
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
let current: Credential.OAuth | undefined = found.value
|
||||
return MCPOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
|
||||
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
current = undefined
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
},
|
||||
store: {
|
||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
||||
saveTokens: async (tokens) => {
|
||||
current = MCPOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
||||
})
|
||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
||||
},
|
||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
saveCodeVerifier: async () => {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
||||
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
entry.tools = defs.map((def) => toTool(name, def))
|
||||
}),
|
||||
)
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
connection.onClose(() => {
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() => {
|
||||
fork(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
const fields = { server, logger: message.logger, level: message.level, data: message.data }
|
||||
switch (message.level) {
|
||||
case "debug":
|
||||
return Effect.logDebug("MCP server log", fields)
|
||||
case "info":
|
||||
case "notice":
|
||||
return Effect.logInfo("MCP server log", fields)
|
||||
case "warning":
|
||||
return Effect.logWarning("MCP server log", fields)
|
||||
case "error":
|
||||
case "critical":
|
||||
case "alert":
|
||||
case "emergency":
|
||||
return Effect.logError("MCP server log", fields)
|
||||
}
|
||||
}
|
||||
|
||||
const startServer = (name: ServerName, entry: ServerEntry) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(result)) {
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.defs.map((def) => toTool(name, def))
|
||||
entry.status = { status: "connected" }
|
||||
watch(name, entry, result.value.connection)
|
||||
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
|
||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
return
|
||||
}
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
entry.scope = undefined
|
||||
const error = Cause.squash(result.cause)
|
||||
entry.status =
|
||||
error instanceof MCPClient.NeedsAuthError
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
||||
|
||||
// Disabled servers settle their startup immediately so queries never block on them.
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry))
|
||||
}
|
||||
|
||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
||||
const owned = new Set(registrations.map((reg) => reg.integrationID))
|
||||
const reconnect = (integrationID: Integration.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||
if (!match) return
|
||||
const [name, entry] = match
|
||||
if (entry.config.disabled) return
|
||||
if (entry.scope) {
|
||||
yield* Scope.close(entry.scope, Exit.void)
|
||||
entry.scope = undefined
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
})
|
||||
fork(
|
||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
|
||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const target = yield* requireServer(server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
return yield* Effect.forEach(entries, ([name, entry]) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = entry.integrationID
|
||||
? yield* integration.connection.active(entry.integrationID)
|
||||
: undefined
|
||||
return info(name, entry, connection)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
tools: Effect.fn("MCP.tools")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(runtime.values())
|
||||
.flatMap((entry) => entry.tools ?? [])
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
||||
}),
|
||||
callTool: Effect.fn("MCP.callTool")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
if (!target.entry.client)
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message })))
|
||||
return new ToolResult({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
isError: result.isError,
|
||||
structured: result.structured,
|
||||
content: result.content,
|
||||
})
|
||||
}),
|
||||
instructions: Effect.fn("MCP.instructions")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(runtime)
|
||||
.flatMap(([server, entry]) => {
|
||||
const instructions = entry.client?.instructions
|
||||
if (!instructions) return []
|
||||
return [new ServerInstructions({ server, instructions })]
|
||||
})
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
}),
|
||||
prompts: Effect.fn("MCP.prompts")(function* () {
|
||||
yield* whenAllReady
|
||||
return []
|
||||
}),
|
||||
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
}),
|
||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||
yield* whenAllReady
|
||||
return new ResourceCatalog({ resources: [], templates: [] })
|
||||
}),
|
||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
|
||||
})
|
||||
238
packages/core/src/mcp/oauth.ts
Normal file
238
packages/core/src/mcp/oauth.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
export * as MCPOAuth from "./oauth"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
||||
export interface Store {
|
||||
readonly tokens: () => Promise<OAuthTokens | undefined>
|
||||
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
|
||||
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
|
||||
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
|
||||
readonly codeVerifier: () => Promise<string | undefined>
|
||||
readonly saveCodeVerifier: (verifier: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/** Loopback URL the authorization server redirects back to after the user approves. */
|
||||
readonly redirectUrl: string
|
||||
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
|
||||
readonly scope?: string
|
||||
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
|
||||
* The caller is responsible for validating the value echoed back to the redirect. */
|
||||
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 }
|
||||
/** 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. */
|
||||
readonly onRedirect: (url: URL) => void | Promise<void>
|
||||
readonly store: Store
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
|
||||
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
|
||||
*/
|
||||
export const provider = (options: Options): OAuthClientProvider => {
|
||||
const state = options.state
|
||||
const client = options.client
|
||||
return {
|
||||
redirectUrl: options.redirectUrl,
|
||||
clientMetadata: {
|
||||
redirect_uris: [options.redirectUrl],
|
||||
client_name: "opencode",
|
||||
client_uri: "https://opencode.ai",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
},
|
||||
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
|
||||
// provider has no redirect to validate, so it omits it.
|
||||
...(state !== undefined ? { state: () => state } : {}),
|
||||
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
|
||||
clientInformation: () =>
|
||||
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
|
||||
saveClientInformation: (info) => options.store.saveClientInformation(info),
|
||||
tokens: () => options.store.tokens(),
|
||||
saveTokens: (tokens) => options.store.saveTokens(tokens),
|
||||
redirectToAuthorization: (url) => options.onRedirect(url),
|
||||
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
|
||||
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
|
||||
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
|
||||
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
|
||||
codeVerifier: async () => {
|
||||
const verifier = await options.store.codeVerifier()
|
||||
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
|
||||
return verifier
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
|
||||
export const memoryStore = (): Store => {
|
||||
let tokens: OAuthTokens | undefined
|
||||
let client: OAuthClientInformationMixed | undefined
|
||||
let verifier: string | undefined
|
||||
return {
|
||||
tokens: async () => tokens,
|
||||
saveTokens: async (value) => {
|
||||
tokens = value
|
||||
},
|
||||
clientInformation: async () => client,
|
||||
saveClientInformation: async (value) => {
|
||||
client = value
|
||||
},
|
||||
codeVerifier: async () => verifier,
|
||||
saveCodeVerifier: async (value) => {
|
||||
verifier = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
|
||||
export const clientFromCredential = (credential: Credential.OAuth) =>
|
||||
credential.metadata?.client as OAuthClientInformationMixed | undefined
|
||||
|
||||
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
|
||||
export const toCredential = (input: {
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly serverUrl: string
|
||||
readonly tokens: OAuthTokens
|
||||
readonly client: OAuthClientInformationMixed | undefined
|
||||
}) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: input.methodID,
|
||||
access: input.tokens.access_token,
|
||||
refresh: input.tokens.refresh_token ?? "",
|
||||
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
|
||||
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
|
||||
metadata: {
|
||||
serverUrl: input.serverUrl,
|
||||
tokenType: input.tokens.token_type,
|
||||
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
|
||||
...(input.client ? { client: input.client } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
|
||||
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
|
||||
const metadata = credential.metadata ?? {}
|
||||
return {
|
||||
access_token: credential.access,
|
||||
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
|
||||
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
|
||||
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
|
||||
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
|
||||
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
|
||||
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
|
||||
*/
|
||||
export const authorize = (input: {
|
||||
readonly name: string
|
||||
readonly config: typeof ConfigMCP.Remote.Type
|
||||
readonly methodID: Integration.MethodID
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const oauth = input.config.oauth || undefined
|
||||
const store = memoryStore()
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== redirectPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
if (error) return fail(error)
|
||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code")
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
|
||||
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
|
||||
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
|
||||
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
|
||||
const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
resume(
|
||||
address && typeof address === "object"
|
||||
? Effect.succeed(address.port)
|
||||
: Effect.fail(new Error("Could not determine MCP OAuth callback port")),
|
||||
)
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
|
||||
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,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
},
|
||||
store,
|
||||
})
|
||||
|
||||
const finalize = Effect.gen(function* () {
|
||||
const tokens = yield* Effect.promise(() => store.tokens())
|
||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
||||
const client = yield* Effect.promise(() => store.clientInformation())
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
return {
|
||||
url: authorizationUrl.toString(),
|
||||
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
|
||||
mode: "auto" as const,
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
),
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue