LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

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:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+545
View File
@@ -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))
}
+221
View File
@@ -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 }
}