The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,545 @@
|
||||
// MCP servers (Model Context Protocol), after Hermes Agent (tools/mcp_tool*.py; MIT, © Nous
|
||||
// Research) and OpenCode (packages/opencode/src/mcp/; MIT, © opencode), on the official SDK.
|
||||
//
|
||||
// - A server is a local process (`command`, stdio) or a URL (streamable HTTP, SSE as fallback).
|
||||
// - Its tools become LLeMbas CLI tools named mcp__<server>__<tool>; each call is checked by the
|
||||
// permission rules like any tool (key = that name), and asks by default.
|
||||
// - Its prompts become /<server>:<prompt> commands; its resources get list/read tools; its
|
||||
// instructions go into the system prompt (OpenCode's <mcp_instructions>).
|
||||
// - Connecting happens in the background; tools appear on the next prompt after a server is up.
|
||||
import { createHash } from "node:crypto"
|
||||
import { isAbsolute, join } from "node:path"
|
||||
import { z } from "zod"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { McpServer } from "../config/schema.ts"
|
||||
import { expandHome } from "../config/paths.ts"
|
||||
import { scanThreats } from "../memory/threats.ts"
|
||||
import { tlsFor } from "../provider/http.ts"
|
||||
import type { ImagePart } from "../provider/types.ts"
|
||||
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
|
||||
import { match } from "../permission/wildcard.ts"
|
||||
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts"
|
||||
|
||||
export type ServerConfig = McpServer & { source: "global" | "project" }
|
||||
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
|
||||
|
||||
interface RemoteTool {
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema: Record<string, unknown>
|
||||
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; title?: string }
|
||||
}
|
||||
interface RemotePrompt {
|
||||
name: string
|
||||
description?: string
|
||||
arguments?: { name: string; description?: string; required?: boolean }[]
|
||||
}
|
||||
|
||||
export interface ServerState {
|
||||
name: string
|
||||
cfg: ServerConfig
|
||||
status: Status
|
||||
error?: string
|
||||
client?: Client
|
||||
transport?: Transport
|
||||
tools: RemoteTool[]
|
||||
prompts: RemotePrompt[]
|
||||
resources: boolean
|
||||
instructions?: string
|
||||
/** The last lines the process wrote to stderr (stdio), for a failure's explanation. */
|
||||
stderr: string[]
|
||||
oauth?: OAuthProvider
|
||||
/** The connect under way, which every other caller waits on rather than starting one. */
|
||||
connecting?: Promise<ServerState>
|
||||
/** Bumped by every connect and disable: a connect that finds it changed gives up quietly. */
|
||||
gen: number
|
||||
loading?: Promise<void>
|
||||
reloadAgain?: boolean
|
||||
}
|
||||
|
||||
export interface McpPromptCommand {
|
||||
/** `server:prompt`, as typed after the slash. */
|
||||
name: string
|
||||
description: string
|
||||
args: string[]
|
||||
server: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
|
||||
/** The LLeMbas library server (`library: lembas`): its tools keep their own names, as in a
|
||||
* LLeMbas chat, and stand in for this machine's memory, notes, skills and knowledge tools. */
|
||||
export const LIBRARY_SERVER = "lembas-library"
|
||||
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
|
||||
export function toolName(server: string, tool: string): string {
|
||||
const full = `mcp__${sanitize(server)}__${sanitize(tool)}`
|
||||
if (full.length <= 64) return full
|
||||
return `${full.slice(0, 55)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`
|
||||
}
|
||||
|
||||
/** include wins; otherwise exclude; names or globs. */
|
||||
export function toolAllowed(name: string, filter: McpServer["tools"]): boolean {
|
||||
if (filter?.include) return filter.include.some((p) => match(name, p))
|
||||
if (filter?.exclude) return !filter.exclude.some((p) => match(name, p))
|
||||
return true
|
||||
}
|
||||
|
||||
/** A tool result from MCP content blocks: text as text, images for the model, the rest named. */
|
||||
export function convertResult(server: string, r: any): ToolResult {
|
||||
const texts: string[] = []
|
||||
const images: ImagePart[] = []
|
||||
for (const b of (r?.content ?? []) as any[]) {
|
||||
if (b.type === "text") texts.push(String(b.text ?? ""))
|
||||
else if (b.type === "image" && typeof b.data === "string") {
|
||||
images.push({ type: "image", mime: b.mimeType ?? "image/png", data: b.data })
|
||||
texts.push(`[image: ${b.mimeType ?? "image"}, attached]`)
|
||||
} else if (b.type === "audio") texts.push(`[audio (${b.mimeType ?? "?"}) — not passed on]`)
|
||||
else if (b.type === "resource") {
|
||||
const res = b.resource ?? {}
|
||||
if (typeof res.text === "string") texts.push(`[resource ${res.uri ?? ""}]\n${res.text}`)
|
||||
else texts.push(`[binary resource ${res.uri ?? ""} (${res.mimeType ?? "?"}, ${typeof res.blob === "string" ? Math.floor((res.blob.length * 3) / 4) : "?"} bytes) — not passed on]`)
|
||||
} else if (b.type === "resource_link") texts.push(`[resource link: ${b.uri}${b.name ? ` (${b.name})` : ""}${b.mimeType ? `, ${b.mimeType}` : ""} — read it with ${toolName(server, "read_resource")}]`)
|
||||
else texts.push(`[unsupported MCP content: ${b.type}]`)
|
||||
}
|
||||
let text = texts.join("\n\n").trim()
|
||||
if (r?.structuredContent !== undefined && (!text || text === JSON.stringify(r.structuredContent))) text = JSON.stringify(r.structuredContent, null, 2)
|
||||
if (!text && r?.toolResult !== undefined) text = typeof r.toolResult === "string" ? r.toolResult : JSON.stringify(r.toolResult)
|
||||
if (r?.isError) return { output: truncate(text || "The MCP tool reported an error."), isError: true, title: "error" }
|
||||
return { output: truncate(text || "(no output)"), ...(images.length ? { images } : {}) }
|
||||
}
|
||||
|
||||
/** The parameters schema as providers want it: an object, with properties. */
|
||||
function objectSchema(s: unknown): Record<string, unknown> {
|
||||
const o = s && typeof s === "object" && !Array.isArray(s) ? { ...(s as Record<string, unknown>) } : {}
|
||||
delete o.$schema
|
||||
if (o.type !== "object") o.type = "object"
|
||||
if (!o.properties) o.properties = {}
|
||||
return o
|
||||
}
|
||||
|
||||
const MAX_INSTRUCTIONS = 4000
|
||||
const LOOSE = z.looseObject({})
|
||||
|
||||
export interface McpOptions {
|
||||
/** The project root: where a local server runs unless it says otherwise. */
|
||||
root: string
|
||||
version: string
|
||||
notice?: (message: string) => void
|
||||
/** Open a URL in the user's browser; false when that is not possible. */
|
||||
open?: (url: string) => boolean
|
||||
}
|
||||
|
||||
/** Local server processes still running, killed when LLeMbas CLI exits however it exits. */
|
||||
const live = new Set<StdioClientTransport>()
|
||||
let exitHook = false
|
||||
|
||||
export class McpManager {
|
||||
readonly servers = new Map<string, ServerState>()
|
||||
private listeners: (() => void)[] = []
|
||||
|
||||
/** Called whenever a server's status, tools or prompts change. */
|
||||
onChange(f: () => void) {
|
||||
this.listeners.push(f)
|
||||
}
|
||||
|
||||
private changed() {
|
||||
for (const f of this.listeners) f()
|
||||
}
|
||||
|
||||
constructor(
|
||||
configs: Record<string, ServerConfig>,
|
||||
private o: McpOptions,
|
||||
) {
|
||||
if (!exitHook) {
|
||||
exitHook = true
|
||||
process.once("exit", () => {
|
||||
for (const t of live)
|
||||
try {
|
||||
if (t.pid) process.kill(t.pid)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
for (const [name, cfg] of Object.entries(configs))
|
||||
this.servers.set(name, { name, cfg, status: cfg.enabled === false ? "disabled" : "connecting", tools: [], prompts: [], resources: false, stderr: [], gen: 0 })
|
||||
}
|
||||
|
||||
/** Connect every enabled server, together; resolves when all have succeeded or failed. */
|
||||
start(): Promise<void> {
|
||||
return Promise.all([...this.servers.values()].filter((s) => s.status !== "disabled").map((s) => this.connect(s.name))).then(() => undefined)
|
||||
}
|
||||
|
||||
private transportFor(s: ServerState, kind: "http" | "sse"): Transport {
|
||||
const cfg = s.cfg
|
||||
if (cfg.command !== undefined) {
|
||||
const [command, ...rest] = Array.isArray(cfg.command) ? cfg.command : [cfg.command]
|
||||
// The server gets a safe minimum of the environment (as Hermes does), plus what its config
|
||||
// names — not every API key in the shell.
|
||||
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
||||
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && (/^(XDG_|LC_)/.test(k) || ["LANG", "TZ", "TMPDIR", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE"].includes(k))) env[k] = v
|
||||
Object.assign(env, cfg.env ?? {})
|
||||
const cwd = cfg.cwd ? (isAbsolute(expandHome(cfg.cwd)) ? expandHome(cfg.cwd) : join(this.o.root, cfg.cwd)) : this.o.root
|
||||
const t = new StdioClientTransport({ command: command!, args: [...rest, ...(cfg.args ?? [])], env, cwd, stderr: "pipe" })
|
||||
live.add(t)
|
||||
const close = t.close.bind(t)
|
||||
t.close = async () => {
|
||||
live.delete(t)
|
||||
await close()
|
||||
}
|
||||
t.stderr?.on("data", (chunk: Buffer) => {
|
||||
s.stderr.push(...chunk.toString().split("\n").filter(Boolean))
|
||||
if (s.stderr.length > 20) s.stderr.splice(0, s.stderr.length - 20)
|
||||
})
|
||||
return t
|
||||
}
|
||||
const url = new URL(cfg.url!)
|
||||
const tls = tlsFor(cfg)
|
||||
const fetchWith = tls ? (u: string | URL, init?: RequestInit) => fetch(u, { ...init, tls } as RequestInit) : undefined
|
||||
if (cfg.oauth !== false) s.oauth ??= new OAuthProvider(s.name, cfg.url!, cfg.oauth ?? {})
|
||||
const opts = { requestInit: { headers: cfg.headers ?? {} }, ...(s.oauth ? { authProvider: s.oauth } : {}), ...(fetchWith ? { fetch: fetchWith } : {}) }
|
||||
return kind === "sse" ? new SSEClientTransport(url, opts) : new StreamableHTTPClientTransport(url, opts)
|
||||
}
|
||||
|
||||
/** (Re)connect one server. Never throws: the outcome is its status. Calls while one is under way
|
||||
* (three tool calls finding it dropped) share it, rather than starting a process each. */
|
||||
connect(name: string): Promise<ServerState> {
|
||||
const s = this.servers.get(name)
|
||||
if (!s) throw new Error(`no MCP server "${name}"`)
|
||||
if (s.connecting) return s.connecting
|
||||
const gen = ++s.gen
|
||||
s.connecting = this.connectNow(s, gen).finally(() => {
|
||||
if (s.gen === gen) s.connecting = undefined
|
||||
})
|
||||
return s.connecting
|
||||
}
|
||||
|
||||
private async connectNow(s: ServerState, gen: number): Promise<ServerState> {
|
||||
await this.disconnect(s)
|
||||
s.status = "connecting"
|
||||
s.error = undefined
|
||||
this.changed()
|
||||
const kinds: ("http" | "sse")[] = s.cfg.command !== undefined ? ["http"] : s.cfg.transport === "sse" ? ["sse"] : ["http", "sse"]
|
||||
for (const [i, kind] of kinds.entries()) {
|
||||
const client = new Client({ name: "lembas-cli", version: this.o.version }, { capabilities: {} })
|
||||
let transport: Transport
|
||||
try {
|
||||
transport = this.transportFor(s, kind)
|
||||
} catch (e) {
|
||||
return this.fail(s, (e as Error).message)
|
||||
}
|
||||
const limit = (s.cfg.connect_timeout ?? 30) * 1000
|
||||
try {
|
||||
await withTimeout(client.connect(transport), limit, `no answer within ${s.cfg.connect_timeout ?? 30} s`)
|
||||
// Disabled or reconnected meanwhile: this connection is nobody's.
|
||||
if (s.gen !== gen) {
|
||||
await client.close().catch(() => {})
|
||||
return s
|
||||
}
|
||||
s.client = client
|
||||
s.transport = transport
|
||||
client.onclose = () => {
|
||||
if (s.client !== client) return
|
||||
s.client = undefined
|
||||
s.status = "failed"
|
||||
s.error = `the connection closed${s.stderr.length ? ` — ${s.stderr[s.stderr.length - 1]}` : ""}`
|
||||
this.changed()
|
||||
}
|
||||
// Set before the first listing, so a change announced during it is not missed.
|
||||
const relist = () => void this.reload(s).then(() => this.changed(), () => {})
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => relist())
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => relist())
|
||||
// The listing is part of connecting: a server that never finishes it must not hang startup.
|
||||
await withTimeout(this.reload(s), limit, `its tools were not listed within ${s.cfg.connect_timeout ?? 30} s`)
|
||||
if (s.gen !== gen) return s
|
||||
s.status = "connected"
|
||||
this.changed()
|
||||
return s
|
||||
} catch (e) {
|
||||
if (s.client === client) {
|
||||
s.client = undefined
|
||||
s.transport = undefined
|
||||
}
|
||||
await client.close().catch(() => {})
|
||||
await transport.close().catch(() => {})
|
||||
if (s.gen !== gen) return s
|
||||
if (e instanceof UnauthorizedError || s.oauth?.authorizationUrl) {
|
||||
s.status = "needs_auth"
|
||||
s.error = `sign-in needed — /mcp auth ${s.name}`
|
||||
this.changed()
|
||||
return s
|
||||
}
|
||||
// Streamable HTTP refused: try SSE once, as Hermes and OpenCode do.
|
||||
if (i < kinds.length - 1) continue
|
||||
const why = (e as Error).message || String(e)
|
||||
return this.fail(s, s.stderr.length ? `${why} — ${s.stderr.slice(-3).join(" | ")}` : why)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fail(s: ServerState, why: string): ServerState {
|
||||
s.status = "failed"
|
||||
s.error = why
|
||||
this.changed()
|
||||
return s
|
||||
}
|
||||
|
||||
/** One listing at a time per server; a change announced during one runs another after it. */
|
||||
private reload(s: ServerState): Promise<void> {
|
||||
if (s.loading) {
|
||||
s.reloadAgain = true
|
||||
return s.loading
|
||||
}
|
||||
s.loading = (async () => {
|
||||
do {
|
||||
s.reloadAgain = false
|
||||
await this.load(s)
|
||||
} while (s.reloadAgain)
|
||||
})().finally(() => (s.loading = undefined))
|
||||
return s.loading
|
||||
}
|
||||
|
||||
/** Tools, prompts, resources and instructions, as the server has them now. */
|
||||
private async load(s: ServerState) {
|
||||
const c = s.client
|
||||
if (!c) return
|
||||
const caps = c.getServerCapabilities() ?? {}
|
||||
// Pages until the server stops giving a cursor — but not forever: a cursor seen before, or a
|
||||
// hundred pages, ends it.
|
||||
const pages = async <T>(get: (cursor?: string) => Promise<{ items: T[]; next?: string }>) => {
|
||||
const out: T[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const r = await get(cursor)
|
||||
out.push(...r.items)
|
||||
if (!r.next || seen.has(r.next)) break
|
||||
seen.add(r.next)
|
||||
cursor = r.next
|
||||
}
|
||||
return out
|
||||
}
|
||||
const tools = caps.tools ? await pages(async (cursor) => {
|
||||
const r = await c.listTools(cursor ? { cursor } : undefined)
|
||||
return { items: r.tools as RemoteTool[], next: r.nextCursor }
|
||||
}) : []
|
||||
const prompts = caps.prompts && s.cfg.prompts !== false ? await pages(async (cursor) => {
|
||||
const r = await c.listPrompts(cursor ? { cursor } : undefined)
|
||||
return { items: r.prompts as RemotePrompt[], next: r.nextCursor }
|
||||
}) : []
|
||||
s.tools = tools.filter((t) => toolAllowed(t.name, s.cfg.tools))
|
||||
s.prompts = prompts
|
||||
s.resources = !!caps.resources && s.cfg.resources !== false
|
||||
const ins = s.cfg.instructions === false ? undefined : c.getInstructions()?.trim()
|
||||
s.instructions = undefined
|
||||
if (ins) {
|
||||
// The server wrote this, not the user: anything aimed at the agent is left out.
|
||||
const threats = scanThreats(ins, "context")
|
||||
if (threats.length) this.o.notice?.(`MCP ${s.name}: its instructions match ${threats.join(", ")} — left out of the prompt`)
|
||||
else s.instructions = ins.length > MAX_INSTRUCTIONS ? `${ins.slice(0, MAX_INSTRUCTIONS)}\n[…cut]` : ins
|
||||
}
|
||||
}
|
||||
|
||||
private async disconnect(s: ServerState) {
|
||||
const c = s.client
|
||||
s.client = undefined
|
||||
s.transport = undefined
|
||||
if (c) await c.close().catch(() => {})
|
||||
}
|
||||
|
||||
/** A connected client, reconnecting once if the connection dropped (Hermes retries once). */
|
||||
private async clientOf(name: string): Promise<Client> {
|
||||
let s = this.servers.get(name)!
|
||||
if (!s.client && s.status !== "disabled" && s.status !== "needs_auth") s = await this.connect(name)
|
||||
if (!s.client) throw new ToolError(`MCP server ${name} is not connected: ${s.error ?? s.status}. /mcp shows the servers.`)
|
||||
return s.client
|
||||
}
|
||||
|
||||
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
||||
tools(): Tool[] {
|
||||
const out: Tool[] = []
|
||||
// Different names can come out the same (get.user and get_user; server a.b and a_b): the
|
||||
// second gets a hash of what it really is, so every tool stays reachable and names stay unique.
|
||||
const used = new Set<string>()
|
||||
const unique = (server: string, tool: string) => {
|
||||
let n = toolName(server, tool)
|
||||
if (used.has(n)) n = `${n.slice(0, 55)}_${createHash("sha256").update(`${server}\u0000${tool}`).digest("hex").slice(0, 8)}`
|
||||
used.add(n)
|
||||
return n
|
||||
}
|
||||
for (const s of this.servers.values()) {
|
||||
if (s.status !== "connected") continue
|
||||
for (const t of s.tools) {
|
||||
const library = s.name === LIBRARY_SERVER
|
||||
const name = library ? sanitize(t.name) : unique(s.name, t.name)
|
||||
if (library) used.add(name)
|
||||
const readOnly = t.annotations?.readOnlyHint === true
|
||||
out.push({
|
||||
name,
|
||||
access: name,
|
||||
description: library ? (t.description ?? t.name).trim() : `${(t.description ?? `${t.name} from the MCP server ${s.name}`).trim()}\n(MCP server: ${s.name})`,
|
||||
schema: LOOSE,
|
||||
jsonSchema: objectSchema(t.inputSchema),
|
||||
// The library's writes are the account's memory and notes, not this machine: asked like
|
||||
// the local memory tool (interact), never run unasked as a command would be denied.
|
||||
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
|
||||
run: async (args, ctx) => {
|
||||
const c = await this.clientOf(s.name)
|
||||
try {
|
||||
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown> }, undefined, {
|
||||
signal: ctx.signal,
|
||||
timeout: (s.cfg.timeout ?? 120) * 1000,
|
||||
resetTimeoutOnProgress: true,
|
||||
onprogress: () => {},
|
||||
})
|
||||
const res = convertResult(s.name, r)
|
||||
const first = res.output.split("\n").find((l) => l.trim()) ?? ""
|
||||
return { ...res, title: res.title ?? (first.length > 60 ? `${first.slice(0, 57)}…` : first) }
|
||||
} catch (e) {
|
||||
throw new ToolError(`${s.name}/${t.name}: ${(e as Error).message}`)
|
||||
}
|
||||
},
|
||||
} as Tool)
|
||||
}
|
||||
if (s.resources) {
|
||||
const list = unique(s.name, "list_resources")
|
||||
const read = unique(s.name, "read_resource")
|
||||
out.push(
|
||||
{
|
||||
name: list,
|
||||
access: list,
|
||||
description: `List the resources the MCP server ${s.name} offers (documents, files, records it can hand over).`,
|
||||
schema: LOOSE,
|
||||
jsonSchema: { type: "object", properties: {} },
|
||||
permission: () => ({ permission: list, class: "read", patterns: ["*"] }),
|
||||
run: async () => {
|
||||
const c = await this.clientOf(s.name)
|
||||
const r = await c.listResources()
|
||||
const rows = r.resources.map((x: any) => `${x.uri}${x.name ? ` — ${x.name}` : ""}${x.mimeType ? ` (${x.mimeType})` : ""}${x.description ? `: ${x.description}` : ""}`)
|
||||
return { output: rows.join("\n") || "(no resources)", title: `${s.name} · ${rows.length} resources` }
|
||||
},
|
||||
} as Tool,
|
||||
{
|
||||
name: read,
|
||||
access: read,
|
||||
description: `Read one resource from the MCP server ${s.name}, by its URI (from ${list}, or a resource link in a tool result).`,
|
||||
schema: z.object({ uri: z.string() }),
|
||||
jsonSchema: { type: "object", properties: { uri: { type: "string", description: "The resource's URI" } }, required: ["uri"] },
|
||||
permission: (a: any) => ({ permission: read, class: "read", patterns: [String(a.uri)] }),
|
||||
run: async (a: any) => {
|
||||
const c = await this.clientOf(s.name)
|
||||
const r = await c.readResource({ uri: a.uri })
|
||||
return { ...convertResult(s.name, { content: r.contents.map((x: any) => ({ type: "resource", resource: x })) }), title: `${s.name} · ${a.uri}` }
|
||||
},
|
||||
} as Tool,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Prompts as /server:prompt commands. */
|
||||
prompts(): McpPromptCommand[] {
|
||||
return [...this.servers.values()]
|
||||
.filter((s) => s.status === "connected")
|
||||
.flatMap((s) => s.prompts.map((p) => ({ name: `${sanitize(s.name)}:${sanitize(p.name)}`, description: p.description ?? `prompt from ${s.name}`, args: (p.arguments ?? []).map((a) => a.name), server: s.name, prompt: p.name })))
|
||||
}
|
||||
|
||||
/** A prompt's text with the typed words as its arguments, in order (the last takes the rest). */
|
||||
async promptText(cmd: McpPromptCommand, typed: string): Promise<string> {
|
||||
const words = typed.trim() ? typed.trim().split(/\s+/) : []
|
||||
const args: Record<string, string> = {}
|
||||
cmd.args.forEach((a, i) => {
|
||||
const v = i === cmd.args.length - 1 ? words.slice(i).join(" ") : words[i]
|
||||
if (v) args[a] = v
|
||||
})
|
||||
const c = await this.clientOf(cmd.server)
|
||||
const r = await c.getPrompt({ name: cmd.prompt, arguments: args })
|
||||
return r.messages
|
||||
.map((m: any) => (m.content?.type === "text" ? m.content.text : m.content?.type === "resource" && typeof m.content.resource?.text === "string" ? m.content.resource.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
/** The system prompt's block of server instructions, or "". */
|
||||
instructions(): string {
|
||||
const parts = [...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => `<server name="${s.name}">\n${s.instructions}\n</server>`)
|
||||
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
|
||||
}
|
||||
|
||||
/** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */
|
||||
async auth(name: string, show: (url: string) => void, pasted?: Promise<string>): Promise<ServerState> {
|
||||
const s = this.servers.get(name)
|
||||
if (!s) throw new Error(`no MCP server "${name}"`)
|
||||
if (s.cfg.url === undefined) throw new Error(`${name} is a local server; it has no sign-in`)
|
||||
if (s.cfg.oauth === false) throw new Error(`${name} has oauth: false`)
|
||||
// A connect already under way uses the old provider; let it finish first.
|
||||
await s.connecting?.catch(() => {})
|
||||
const oauth = new OAuthProvider(s.name, s.cfg.url, s.cfg.oauth ?? {})
|
||||
s.oauth = oauth
|
||||
const port = (s.cfg.oauth || undefined)?.callback_port ?? CALLBACK_PORT
|
||||
const cb = waitForCallback(port, () => oauth.expectedState())
|
||||
try {
|
||||
if (cb.error && !pasted) throw cb.error
|
||||
if (cb.error) this.o.notice?.(cb.error.message)
|
||||
await this.connect(name)
|
||||
if (s.status === "connected") return s
|
||||
const url = oauth.authorizationUrl
|
||||
if (!url) throw new Error(s.error ?? "the server did not ask for a sign-in")
|
||||
show(url.toString())
|
||||
this.o.open?.(url.toString())
|
||||
const got = await Promise.race([
|
||||
// The listener has checked the state itself.
|
||||
...(cb.error ? [] : [cb.code.then((code) => ({ code, state: undefined as string | undefined, fromUrl: false }))]),
|
||||
...(pasted ? [pasted.then(codeFrom)] : []),
|
||||
])
|
||||
const want = oauth.expectedState()
|
||||
// A pasted address must carry this sign-in's state; a bare code (some servers show one) is taken.
|
||||
if (want && (got.state !== undefined || got.fromUrl) && got.state !== want) throw new Error("OAuth state mismatch — that address is from another sign-in")
|
||||
const t = this.transportFor(s, s.cfg.transport === "sse" ? "sse" : "http") as StreamableHTTPClientTransport | SSEClientTransport
|
||||
await t.finishAuth(got.code)
|
||||
await t.close().catch(() => {})
|
||||
oauth.authorizationUrl = undefined
|
||||
return await this.connect(name)
|
||||
} finally {
|
||||
cb.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a server's stored sign-in. */
|
||||
logout(name: string) {
|
||||
forget(name)
|
||||
const s = this.servers.get(name)
|
||||
if (s) s.oauth = undefined
|
||||
}
|
||||
|
||||
async setEnabled(name: string, on: boolean) {
|
||||
const s = this.servers.get(name)
|
||||
if (!s) throw new Error(`no MCP server "${name}"`)
|
||||
if (!on) {
|
||||
// A connect under way sees the changed generation and gives up.
|
||||
s.gen++
|
||||
s.connecting = undefined
|
||||
await this.disconnect(s)
|
||||
s.status = "disabled"
|
||||
this.changed()
|
||||
return s
|
||||
}
|
||||
return this.connect(name)
|
||||
}
|
||||
|
||||
async close() {
|
||||
await Promise.all([...this.servers.values()].map((s) => this.disconnect(s)))
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let t: ReturnType<typeof setTimeout>
|
||||
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// OAuth for remote MCP servers, after OpenCode (packages/opencode/src/mcp/oauth-provider.ts,
|
||||
// oauth-callback.ts, auth.ts; MIT, © opencode): the SDK does discovery, dynamic registration,
|
||||
// PKCE and refresh; this keeps what it hands over (client, tokens) in
|
||||
// ~/.local/share/lembas/mcp-auth.json (mode 600), keyed by server and checked against its URL,
|
||||
// and catches the redirect on 127.0.0.1. A sign-in's state and PKCE verifier stay in memory, with
|
||||
// the sign-in: another LLeMbas CLI connecting meanwhile must not replace them.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { paths } from "../config/paths.ts"
|
||||
import type { McpServer } from "../config/schema.ts"
|
||||
|
||||
export const CALLBACK_PORT = 19876
|
||||
export const CALLBACK_PATH = "/mcp/oauth/callback"
|
||||
|
||||
interface Entry {
|
||||
url: string
|
||||
client?: OAuthClientInformationMixed
|
||||
tokens?: OAuthTokens
|
||||
}
|
||||
|
||||
const file = () => join(paths.data, "mcp-auth.json")
|
||||
|
||||
/** Every stored sign-in. `strict` (before a write): a file that does not parse is an error, not
|
||||
* an empty one — writing over it would lose every sign-in in it. */
|
||||
function readAll(strict = false): Record<string, Entry> {
|
||||
if (!existsSync(file())) return {}
|
||||
try {
|
||||
return JSON.parse(readFileSync(file(), "utf8")) as Record<string, Entry>
|
||||
} catch (e) {
|
||||
if (strict) throw new Error(`${file()} is not valid JSON (${(e as Error).message}); fix or delete it`)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(all: Record<string, Entry>) {
|
||||
mkdirSync(paths.data, { recursive: true })
|
||||
const tmp = `${file()}.${process.pid}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 })
|
||||
chmodSync(tmp, 0o600)
|
||||
renameSync(tmp, file())
|
||||
}
|
||||
|
||||
// Stored by name and URL together: a project's server with the same name as one of yours, at
|
||||
// another address, must not replace (or forget) your sign-in. Entries from before were stored by
|
||||
// name alone, and are still read when their URL matches.
|
||||
const keyOf = (name: string, url: string) => `${name} ${url}`
|
||||
|
||||
/** What is stored for a server, if it was stored for this URL (a changed URL starts over). */
|
||||
export function authEntry(name: string, url: string): Entry | undefined {
|
||||
const all = readAll()
|
||||
const e = all[keyOf(name, url)] ?? all[name]
|
||||
return e && e.url === url ? e : undefined
|
||||
}
|
||||
|
||||
function update(name: string, url: string, change: (e: Entry) => void) {
|
||||
const all = readAll(true)
|
||||
const old = all[keyOf(name, url)] ?? (all[name]?.url === url ? all[name] : undefined)
|
||||
const e: Entry = old ?? { url }
|
||||
change(e)
|
||||
if (all[name]?.url === url) delete all[name]
|
||||
all[keyOf(name, url)] = e
|
||||
writeAll(all)
|
||||
}
|
||||
|
||||
/** Forget a server's sign-in: at this URL, or (no URL) every one stored under the name. */
|
||||
export function forget(name: string, url?: string) {
|
||||
const all = readAll(true)
|
||||
for (const k of Object.keys(all)) if ((k === name && (url === undefined || all[k]!.url === url)) || (url === undefined ? k.startsWith(`${name} `) : k === keyOf(name, url))) delete all[k]
|
||||
writeAll(all)
|
||||
}
|
||||
|
||||
type OAuthConfig = Exclude<NonNullable<McpServer["oauth"]>, false>
|
||||
|
||||
export class OAuthProvider implements OAuthClientProvider {
|
||||
/** Set when the SDK wants the user to authorise: the page to open. */
|
||||
authorizationUrl?: URL
|
||||
private stateValue?: string
|
||||
private verifier?: string
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
private url: string,
|
||||
private cfg: OAuthConfig = {},
|
||||
) {}
|
||||
|
||||
get redirectUrl() {
|
||||
return `http://127.0.0.1:${this.cfg.callback_port ?? CALLBACK_PORT}${CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
client_name: "LLeMbas CLI",
|
||||
redirect_uris: [this.redirectUrl],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: this.cfg.client_secret ? "client_secret_post" : "none",
|
||||
...(this.cfg.scope ? { scope: this.cfg.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
state(): string {
|
||||
this.stateValue = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return this.stateValue
|
||||
}
|
||||
|
||||
expectedState(): string | undefined {
|
||||
return this.stateValue
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformationMixed | undefined {
|
||||
if (this.cfg.client_id) return { client_id: this.cfg.client_id, ...(this.cfg.client_secret ? { client_secret: this.cfg.client_secret } : {}) }
|
||||
return authEntry(this.name, this.url)?.client
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformationMixed) {
|
||||
update(this.name, this.url, (e) => (e.client = info))
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return authEntry(this.name, this.url)?.tokens
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
update(this.name, this.url, (e) => (e.tokens = tokens))
|
||||
}
|
||||
|
||||
redirectToAuthorization(url: URL) {
|
||||
this.authorizationUrl = url
|
||||
}
|
||||
|
||||
saveCodeVerifier(v: string) {
|
||||
this.verifier = v
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this.verifier) throw new Error("no PKCE verifier — start the sign-in again")
|
||||
return this.verifier
|
||||
}
|
||||
|
||||
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
|
||||
if (scope === "verifier" || scope === "all") this.verifier = undefined
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
if (scope === "all") return forget(this.name, this.url)
|
||||
update(this.name, this.url, (e) => {
|
||||
if (scope === "client") delete e.client
|
||||
if (scope === "tokens") delete e.tokens
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The authorisation code from what the user has: the whole redirect URL, or the code itself. */
|
||||
export function codeFrom(input: string): { code: string; state?: string; fromUrl?: boolean } {
|
||||
const t = input.trim()
|
||||
try {
|
||||
const u = new URL(t)
|
||||
const code = u.searchParams.get("code")
|
||||
if (code) return { code, state: u.searchParams.get("state") ?? undefined, fromUrl: true }
|
||||
} catch {}
|
||||
return { code: t }
|
||||
}
|
||||
|
||||
const esc = (t: string) => t.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
||||
|
||||
/** Wait for the browser's redirect on 127.0.0.1. `code` resolves with the code, or rejects on an
|
||||
* error from the server or after `timeoutMs`. Only a request carrying this sign-in's state is
|
||||
* acted on: any web page can reach 127.0.0.1, and must not be able to end the sign-in. `error`:
|
||||
* the port could not be listened on (a pasted address is then the only way). */
|
||||
export function waitForCallback(port: number, expectedState: () => string | undefined, timeoutMs = 5 * 60_000): { code: Promise<string>; stop(): void; error?: Error } {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const stop = () => {
|
||||
clearTimeout(timer)
|
||||
server?.stop(true)
|
||||
server = undefined
|
||||
}
|
||||
const page = (title: string, text: string, status = 200) =>
|
||||
new Response(`<!doctype html><meta charset="utf-8"><title>${esc(title)}</title><body style="font-family:sans-serif;padding:2em"><h2>${esc(title)}</h2><p>${esc(text)}</p>`, {
|
||||
status,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
})
|
||||
let resolve!: (c: string) => void
|
||||
let reject!: (e: Error) => void
|
||||
const code = new Promise<string>((a, b) => ((resolve = a), (reject = b)))
|
||||
// Whoever races it handles a rejection; one nobody is waiting for is not an error.
|
||||
code.catch(() => {})
|
||||
let error: Error | undefined
|
||||
try {
|
||||
server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(req) {
|
||||
const u = new URL(req.url)
|
||||
if (u.pathname !== CALLBACK_PATH) return new Response("not found", { status: 404 })
|
||||
const want = expectedState()
|
||||
if (!want || u.searchParams.get("state") !== want) return page("Not this sign-in", "This address does not belong to the sign-in LLeMbas CLI is waiting for.", 400)
|
||||
const err = u.searchParams.get("error")
|
||||
if (err) {
|
||||
setTimeout(stop, 100)
|
||||
const detail = u.searchParams.get("error_description")
|
||||
reject(new Error(`the server refused: ${err}${detail ? ` — ${detail}` : ""}`))
|
||||
return page("Sign-in failed", `The server said: ${err}. You can close this tab.`)
|
||||
}
|
||||
const c = u.searchParams.get("code")
|
||||
if (!c) return page("No code", "This address carried no authorisation code.", 400)
|
||||
setTimeout(stop, 100)
|
||||
resolve(c)
|
||||
return page("Signed in", "LLeMbas CLI has the authorisation. You can close this tab.")
|
||||
},
|
||||
})
|
||||
timer = setTimeout(() => {
|
||||
stop()
|
||||
reject(new Error("no sign-in within 5 minutes"))
|
||||
}, timeoutMs)
|
||||
} catch (e) {
|
||||
error = new Error(`cannot listen on 127.0.0.1:${port} for the sign-in redirect (${(e as Error).message}); paste the redirect address instead`)
|
||||
reject(error)
|
||||
}
|
||||
return { code, stop, error }
|
||||
}
|
||||
Reference in new issue
Block a user