diff --git a/.env.example b/.env.example index cb32afe..7498181 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,9 @@ BINANCE_TESTNET_BASE_URL=https://testnet.binance.vision # Mainnet MCP: only needed once real funds and MCP OAuth are in play. BINANCE_MCP_URL=https://agent.binance.com/mcp/agentic -# OAuth token is obtained interactively and persisted to data/mcp-token.json. No static secret here. +# Login is interactive (`charter mcp login`) and stored in data/mcp-auth.json. No static secret here. +# CHARTER_MCP_CLIENT_METADATA_URL=https://angelraph.github.io/charter/mcp-client.json +# CHARTER_MCP_CALLBACK_PORT=8976 # Mandate compiler: optional, falls back to manual JSON editing if unset. OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore index 4a0e1ab..d12e1ed 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ data/mandates/ data/mcp-token.json *.log .DS_Store +data/mcp-auth.json +data/mcp-tools.json diff --git a/README.md b/README.md index 17b3c8e..add1cdb 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ CHARTER always executes against a real order-matching engine. It never fabricate `mainnet-mcp` points at the real [Binance Agent OS MCP server](https://developers.binance.com/en/docs/agent-native/mcp-server), against a real, self-funded Agentic sub-account. It's used only where explicitly stated, with a small amount of real funds. +The MCP connection is not finished, and this is where it stands. `charter mcp probe` reads the server's public OAuth metadata (verified live: public client, PKCE S256, authorization code grant only, no dynamic registration, no refresh tokens). `charter mcp login`, `tools` and `status` implement the sign-in as the server describes it: the client identifies itself with an HTTPS URL that hosts [docs/mcp-client.json](docs/mcp-client.json), the browser consent happens on Binance's own page, and the code returns to a loopback listener that checks `state`. The flow is tested against a fake authorization server, including that the PKCE verifier matches the challenge. It has not been run against Binance's live consent page, so whether Binance accepts this client is unconfirmed. Order placement through MCP is not built; it waits on seeing the real tool schemas that `charter mcp tools` saves after a login. + Every audit log entry records which venue produced it. Check `data/audit.log.jsonl` to see exactly which fills were testnet and which, if any, were mainnet. ## Setup diff --git a/docs/mcp-client.json b/docs/mcp-client.json new file mode 100644 index 0000000..8cd857b --- /dev/null +++ b/docs/mcp-client.json @@ -0,0 +1,9 @@ +{ + "client_id": "https://angelraph.github.io/charter/mcp-client.json", + "client_name": "CHARTER", + "client_uri": "https://github.com/angelraph/charter", + "redirect_uris": ["http://127.0.0.1:8976/callback"], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" +} diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..474c517 --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,73 @@ +import { mkdirSync, existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { config } from "../../config.js"; +import { AuthStore } from "../../mcp/authStore.js"; +import { AUTH_FILE, connectMcp, NotLoggedInError } from "../../mcp/session.js"; +import { probeAuth } from "../../mcp/probe.js"; + +const TOOLS_FILE = "./data/mcp-tools.json"; + +export async function mcpProbeCommand(): Promise { + const p = await probeAuth(config.mcp.url); + console.log(`Server: ${p.serverUrl}`); + console.log(`Unauthenticated request: HTTP ${p.status}${p.status === 401 ? " (wants OAuth)" : ""}`); + console.log(`Authorization server: ${p.authorizationServers.join(", ") || "not found"}`); + console.log(`Authorize endpoint: ${p.authorizationEndpoint ?? "not found"}`); + console.log(`Token endpoint: ${p.tokenEndpoint ?? "not found"}`); + console.log(`Grant types: ${p.grantTypes.join(", ") || "none listed"}`); + console.log(`PKCE methods: ${p.codeChallengeMethods.join(", ") || "none listed"}`); + console.log(`Client authentication: ${p.tokenEndpointAuthMethods.join(", ") || "none listed"}`); + console.log(`Client ID metadata documents: ${p.supportsClientIdMetadataDocument ? "supported" : "not supported"}`); + console.log(`Dynamic client registration: ${p.supportsDynamicRegistration ? "supported" : "not supported"}`); + console.log(`Refresh tokens: ${p.offersRefreshTokens ? "offered" : "not offered"}`); + for (const n of p.notes) console.log(`\n${n}`); + console.log(`\nCHARTER identifies itself as: ${config.mcp.clientMetadataUrl}`); + console.log("That URL has to serve docs/mcp-client.json over HTTPS before the server can accept a login."); +} + +export async function mcpLoginCommand(): Promise { + console.log(`Connecting to ${config.mcp.url} ...`); + const session = await connectMcp({ interactive: true }); + try { + const { tools } = await session.client.listTools(); + console.log(`\nLogged in. The server offers ${tools.length} tool${tools.length === 1 ? "" : "s"}. Run \`charter mcp tools\` to see them.`); + } finally { + await session.close(); + } +} + +export async function mcpToolsCommand(): Promise { + let session; + try { + session = await connectMcp({ interactive: false }); + } catch (err) { + if (err instanceof NotLoggedInError) throw err; + throw err; + } + try { + const { tools } = await session.client.listTools(); + for (const t of tools) { + const props = (t.inputSchema as { properties?: Record; required?: string[] } | undefined) ?? {}; + const names = Object.keys(props.properties ?? {}); + const required = new Set(props.required ?? []); + const args = names.map((n) => (required.has(n) ? n : `${n}?`)).join(", "); + console.log(`${t.name}(${args})`); + if (t.description) console.log(` ${t.description.split("\n")[0]}`); + } + const dir = path.dirname(TOOLS_FILE); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + await writeFile(TOOLS_FILE, JSON.stringify(tools, null, 2), "utf-8"); + console.log(`\nFull tool definitions, including argument schemas, saved to ${TOOLS_FILE}`); + } finally { + await session.close(); + } +} + +export async function mcpStatusCommand(): Promise { + const auth = await new AuthStore(AUTH_FILE).read(); + console.log(`Client ID: ${config.mcp.clientMetadataUrl}`); + console.log(`Callback: http://127.0.0.1:${config.mcp.callbackPort}/callback`); + console.log(`Stored login: ${auth.tokens ? "yes" : "none"}`); + if (auth.tokens) console.log(`Refresh token: ${auth.tokens.refresh_token ? "yes" : "no (login ends with the access token)"}`); +} diff --git a/src/config.ts b/src/config.ts index 50acefe..b4d81b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,10 @@ const ConfigSchema = z.object({ }), mcp: z.object({ url: z.string().url(), + /** HTTPS URL of the client metadata document that identifies CHARTER to the MCP server's authorization server. */ + clientMetadataUrl: z.string().url(), + /** Local port the OAuth redirect comes back to. Must match a redirect URI in the client metadata document. */ + callbackPort: z.coerce.number().int().min(1024).max(65535), }), openaiApiKey: z.string().optional(), openaiModel: z.string(), @@ -34,6 +38,8 @@ function loadConfig(): Config { }, mcp: { url: process.env.BINANCE_MCP_URL ?? "https://agent.binance.com/mcp/agentic", + clientMetadataUrl: process.env.CHARTER_MCP_CLIENT_METADATA_URL ?? "https://angelraph.github.io/charter/mcp-client.json", + callbackPort: process.env.CHARTER_MCP_CALLBACK_PORT ?? 8976, }, openaiApiKey: process.env.OPENAI_API_KEY || undefined, openaiModel: process.env.OPENAI_MODEL ?? "gpt-4o-mini", diff --git a/src/index.ts b/src/index.ts index 61bf282..649d230 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { auditTailCommand, auditVerifyCommand } from "./cli/commands/audit.js"; import { mandateCompileCommand } from "./cli/commands/mandate.js"; import { startApiServer } from "./api/server.js"; import { startDashboard } from "./cli/ui.js"; +import { mcpLoginCommand, mcpProbeCommand, mcpStatusCommand, mcpToolsCommand } from "./cli/commands/mcp.js"; import { agentAddCommand, agentListCommand, agentRevokeCommand, agentRotateCommand } from "./cli/commands/agents.js"; import { approvalsListCommand, @@ -216,6 +217,36 @@ agent await agentListCommand(); }); +const mcp = program.command("mcp").description("Connect to the Binance Agent OS MCP server (experimental)"); + +mcp + .command("probe") + .description("Show how the server says to authenticate. Public information, no login needed.") + .action(async () => { + await mcpProbeCommand(); + }); + +mcp + .command("login") + .description("Approve CHARTER in your browser. Needs a real Binance login.") + .action(async () => { + await mcpLoginCommand(); + }); + +mcp + .command("tools") + .description("List the tools the server offers and save their schemas") + .action(async () => { + await mcpToolsCommand(); + }); + +mcp + .command("status") + .description("Show whether a login is stored") + .action(async () => { + await mcpStatusCommand(); + }); + const audit = program.command("audit").description("Inspect the hash-chained audit log"); audit diff --git a/src/mcp/authStore.ts b/src/mcp/authStore.ts new file mode 100644 index 0000000..c368781 --- /dev/null +++ b/src/mcp/authStore.ts @@ -0,0 +1,46 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** Everything the OAuth flow needs to remember between the redirect out and the code coming back. */ +export interface StoredAuth { + tokens?: OAuthTokens; + codeVerifier?: string; + state?: string; + clientInformation?: OAuthClientInformationMixed; +} + +/** + * Persists OAuth state to one JSON file (gitignored, under data/). Tokens are + * bearer credentials for the account, so the file is created readable by the + * owner only. On Windows that mode is advisory; keep the file out of shared + * folders. + */ +export class AuthStore { + constructor(private readonly filePath: string) {} + + async read(): Promise { + if (!existsSync(this.filePath)) return {}; + try { + return JSON.parse(await readFile(this.filePath, "utf-8")) as StoredAuth; + } catch { + return {}; + } + } + + async update(patch: Partial): Promise { + const dir = path.dirname(this.filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const next = { ...(await this.read()), ...patch }; + await writeFile(this.filePath, JSON.stringify(next, null, 2), { encoding: "utf-8", mode: 0o600 }); + } + + async clear(keys: Array): Promise { + const current = await this.read(); + for (const k of keys) delete current[k]; + const dir = path.dirname(this.filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + await writeFile(this.filePath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); + } +} diff --git a/src/mcp/loopback.ts b/src/mcp/loopback.ts new file mode 100644 index 0000000..b527b29 --- /dev/null +++ b/src/mcp/loopback.ts @@ -0,0 +1,77 @@ +import { createServer, type Server } from "node:http"; + +export interface LoopbackHandle { + /** Resolves with the authorization code once the browser comes back. Rejects on a wrong state, an error from the server, or a timeout. */ + code: Promise; + close(): void; +} + +/** + * Listens on 127.0.0.1 for the OAuth redirect. Binds to the loopback + * interface only, so nothing outside this machine can send it a code, and + * checks the `state` value so a redirect that this login did not start is + * refused rather than accepted. + */ +export function listenForAuthorizationCode(options: { port: number; expectedState: () => Promise; timeoutMs?: number }): Promise { + const timeoutMs = options.timeoutMs ?? 5 * 60_000; + + return new Promise((resolveHandle, rejectHandle) => { + let server: Server; + let settled = false; + let timer: NodeJS.Timeout; + + const code = new Promise((resolveCode, rejectCode) => { + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + // Let the response flush before the listener goes away. + setTimeout(() => server.close(), 50); + }; + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${options.port}`); + if (url.pathname !== "/callback") { + res.writeHead(404).end("Not found"); + return; + } + + const reply = (status: number, message: string) => + res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(message); + + const error = url.searchParams.get("error"); + if (error) { + reply(400, `Authorization failed: ${error}. You can close this tab.`); + finish(() => rejectCode(new Error(`Authorization server returned an error: ${error} ${url.searchParams.get("error_description") ?? ""}`.trim()))); + return; + } + + const expected = await options.expectedState(); + if (!expected || url.searchParams.get("state") !== expected) { + reply(400, "This redirect does not match a login CHARTER started. Ignored."); + finish(() => rejectCode(new Error("Redirect had a missing or wrong state value, so it was refused"))); + return; + } + + const received = url.searchParams.get("code"); + if (!received) { + reply(400, "No authorization code in the redirect."); + finish(() => rejectCode(new Error("Redirect carried no authorization code"))); + return; + } + + reply(200, "CHARTER is connected. You can close this tab and return to the terminal."); + finish(() => resolveCode(received)); + }); + + timer = setTimeout(() => finish(() => rejectCode(new Error(`No authorization came back within ${Math.round(timeoutMs / 1000)}s`))), timeoutMs); + }); + + // Avoid an unhandled rejection if the caller only attaches later. + code.catch(() => undefined); + + server!.once("error", (err) => rejectHandle(err)); + server!.listen(options.port, "127.0.0.1", () => resolveHandle({ code, close: () => server.close() })); + }); +} diff --git a/src/mcp/probe.ts b/src/mcp/probe.ts new file mode 100644 index 0000000..bf0ace7 --- /dev/null +++ b/src/mcp/probe.ts @@ -0,0 +1,109 @@ +/** + * Reads what the MCP server publicly says about how to authenticate to it. + * Everything here is an unauthenticated GET or the 401 to an unauthenticated + * POST, the same requests any MCP client makes on its first connection. No + * credential is involved and nothing is bypassed. + */ + +export interface AuthProbe { + serverUrl: string; + /** HTTP status of an unauthenticated initialize request. 401 means the server wants OAuth. */ + status: number; + resourceMetadataUrl?: string; + authorizationServers: string[]; + authorizationEndpoint?: string; + tokenEndpoint?: string; + grantTypes: string[]; + codeChallengeMethods: string[]; + tokenEndpointAuthMethods: string[]; + supportsClientIdMetadataDocument: boolean; + supportsDynamicRegistration: boolean; + offersRefreshTokens: boolean; + notes: string[]; +} + +type Fetch = typeof fetch; + +export async function probeAuth(serverUrl: string, fetchImpl: Fetch = fetch): Promise { + const result: AuthProbe = { + serverUrl, + status: 0, + authorizationServers: [], + grantTypes: [], + codeChallengeMethods: [], + tokenEndpointAuthMethods: [], + supportsClientIdMetadataDocument: false, + supportsDynamicRegistration: false, + offersRefreshTokens: false, + notes: [], + }; + + const init = await fetchImpl(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "charter-probe", version: "0.1.0" } }, + }), + }); + result.status = init.status; + + if (init.status !== 401) { + result.notes.push(init.status === 200 ? "The server accepted an unauthenticated request. It does not require OAuth." : `Unexpected status ${init.status} from an unauthenticated request.`); + return result; + } + + const challenge = init.headers.get("www-authenticate") ?? ""; + const match = /resource_metadata="([^"]+)"/.exec(challenge); + if (!match) { + result.notes.push("The 401 did not say where to find the OAuth metadata (no resource_metadata in WWW-Authenticate)."); + return result; + } + result.resourceMetadataUrl = match[1]; + + const resource = await fetchImpl(match[1]!); + if (!resource.ok) { + result.notes.push(`Could not read the resource metadata at ${match[1]} (HTTP ${resource.status}).`); + return result; + } + const resourceJson = (await resource.json()) as { authorization_servers?: string[] }; + result.authorizationServers = resourceJson.authorization_servers ?? []; + + const issuer = result.authorizationServers[0]; + if (!issuer) { + result.notes.push("The resource metadata named no authorization server."); + return result; + } + + const asRes = await fetchImpl(`${issuer.replace(/\/$/, "")}/.well-known/oauth-authorization-server`); + if (!asRes.ok) { + result.notes.push(`Could not read the authorization server metadata for ${issuer} (HTTP ${asRes.status}).`); + return result; + } + const as = (await asRes.json()) as { + authorization_endpoint?: string; + token_endpoint?: string; + registration_endpoint?: string; + grant_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + token_endpoint_auth_methods_supported?: string[]; + client_id_metadata_document_supported?: boolean; + }; + + result.authorizationEndpoint = as.authorization_endpoint; + result.tokenEndpoint = as.token_endpoint; + result.grantTypes = as.grant_types_supported ?? []; + result.codeChallengeMethods = as.code_challenge_methods_supported ?? []; + result.tokenEndpointAuthMethods = as.token_endpoint_auth_methods_supported ?? []; + result.supportsClientIdMetadataDocument = as.client_id_metadata_document_supported === true; + result.supportsDynamicRegistration = Boolean(as.registration_endpoint); + result.offersRefreshTokens = result.grantTypes.includes("refresh_token"); + + if (!result.offersRefreshTokens) result.notes.push("No refresh_token grant is offered, so a login lasts only as long as its access token and then needs a person to approve again."); + if (!result.supportsDynamicRegistration && result.supportsClientIdMetadataDocument) { + result.notes.push("There is no dynamic registration. A client identifies itself with an HTTPS URL that hosts its metadata document."); + } + return result; +} diff --git a/src/mcp/provider.ts b/src/mcp/provider.ts new file mode 100644 index 0000000..e414a50 --- /dev/null +++ b/src/mcp/provider.ts @@ -0,0 +1,89 @@ +import { randomBytes } from "node:crypto"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthStore } from "./authStore.js"; + +export interface ProviderOptions { + store: AuthStore; + /** HTTPS URL of the client metadata document. It doubles as CHARTER's client_id. */ + clientMetadataUrl: string; + /** Where the browser is sent back with the authorization code. */ + redirectUrl: string; + /** Sends the user to Binance's consent page. Injected so it can be a browser launch in the CLI and a spy in tests. */ + openBrowser: (url: URL) => void | Promise; +} + +/** + * OAuth for the Binance Agent OS MCP server. The server advertises a public + * client (no secret), PKCE, the authorization_code grant only, and no dynamic + * registration. Instead it accepts a client_id that is an HTTPS URL of a + * metadata document, which is what `clientMetadataUrl` is. + * + * No refresh token is offered by the server, so when the access token + * expires the person has to consent again. + */ +export class CharterOAuthProvider implements OAuthClientProvider { + readonly clientMetadataUrl: string; + + constructor(private readonly opts: ProviderOptions) { + this.clientMetadataUrl = opts.clientMetadataUrl; + } + + get redirectUrl(): string { + return this.opts.redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: "CHARTER", + client_uri: "https://github.com/angelraph/charter", + redirect_uris: [this.opts.redirectUrl], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; + } + + async state(): Promise { + const state = randomBytes(24).toString("base64url"); + await this.opts.store.update({ state }); + return state; + } + + async clientInformation(): Promise { + return (await this.opts.store.read()).clientInformation; + } + + async saveClientInformation(info: OAuthClientInformationMixed): Promise { + await this.opts.store.update({ clientInformation: info }); + } + + async tokens(): Promise { + return (await this.opts.store.read()).tokens; + } + + async saveTokens(tokens: OAuthTokens): Promise { + await this.opts.store.update({ tokens }); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await this.opts.store.update({ codeVerifier }); + } + + async codeVerifier(): Promise { + const verifier = (await this.opts.store.read()).codeVerifier; + if (!verifier) throw new Error("No PKCE code verifier saved. Start the login again with `charter mcp login`."); + return verifier; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + await this.opts.openBrowser(authorizationUrl); + } + + async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise { + if (scope === "all") await this.opts.store.clear(["tokens", "codeVerifier", "state", "clientInformation"]); + else if (scope === "tokens") await this.opts.store.clear(["tokens"]); + else if (scope === "client") await this.opts.store.clear(["clientInformation"]); + else if (scope === "verifier") await this.opts.store.clear(["codeVerifier"]); + } +} diff --git a/src/mcp/session.ts b/src/mcp/session.ts new file mode 100644 index 0000000..3322e87 --- /dev/null +++ b/src/mcp/session.ts @@ -0,0 +1,83 @@ +import open from "open"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { config } from "../config.js"; +import { AuthStore } from "./authStore.js"; +import { CharterOAuthProvider } from "./provider.js"; +import { listenForAuthorizationCode, type LoopbackHandle } from "./loopback.js"; + +export const AUTH_FILE = "./data/mcp-auth.json"; + +export interface McpSession { + client: Client; + close(): Promise; +} + +export class NotLoggedInError extends Error { + constructor() { + super("Not logged in to the Binance MCP server. Run `charter mcp login` first."); + this.name = "NotLoggedInError"; + } +} + +export interface ConnectOptions { + /** True to send the person to a browser to approve if there is no valid login. False fails with NotLoggedInError instead. */ + interactive: boolean; + serverUrl?: string; + clientMetadataUrl?: string; + callbackPort?: number; + authFile?: string; + openBrowser?: (url: URL) => void | Promise; +} + +export async function connectMcp(options: ConnectOptions): Promise { + const serverUrl = new URL(options.serverUrl ?? config.mcp.url); + const port = options.callbackPort ?? config.mcp.callbackPort; + const store = new AuthStore(options.authFile ?? AUTH_FILE); + + const provider = new CharterOAuthProvider({ + store, + clientMetadataUrl: options.clientMetadataUrl ?? config.mcp.clientMetadataUrl, + redirectUrl: `http://127.0.0.1:${port}/callback`, + openBrowser: + options.openBrowser ?? + (async (url) => { + console.log("\nApprove the connection in your browser. If it does not open, open this address yourself:\n"); + console.log(` ${url.toString()}\n`); + await open(url.toString()); + }), + }); + + const attempt = () => { + const transport = new StreamableHTTPClientTransport(serverUrl, { authProvider: provider }); + const client = new Client({ name: "charter", version: "0.1.0" }); + return { client, transport }; + }; + + // The redirect to the browser happens inside connect(), so the local + // listener has to be up before it, not after the failure. + let loopback: LoopbackHandle | undefined; + if (options.interactive) { + loopback = await listenForAuthorizationCode({ port, expectedState: async () => (await store.read()).state }); + } + + let { client, transport } = attempt(); + try { + await client.connect(transport); + loopback?.close(); + } catch (err) { + if (!(err instanceof UnauthorizedError)) { + loopback?.close(); + throw err; + } + if (!loopback) throw new NotLoggedInError(); + + const code = await loopback.code; + await transport.finishAuth(code); + ({ client, transport } = attempt()); + await client.connect(transport); + } + + return { client, close: () => client.close() }; +} diff --git a/tests/mcp.auth.test.ts b/tests/mcp.auth.test.ts new file mode 100644 index 0000000..e26496a --- /dev/null +++ b/tests/mcp.auth.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createServer, type Server } from "node:http"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { AuthStore } from "../src/mcp/authStore.js"; +import { CharterOAuthProvider } from "../src/mcp/provider.js"; +import { listenForAuthorizationCode } from "../src/mcp/loopback.js"; +import { probeAuth } from "../src/mcp/probe.js"; + +const CLIENT_ID_URL = "https://example.test/mcp-client.json"; + +function tempStore(): AuthStore { + return new AuthStore(path.join(mkdtempSync(path.join(tmpdir(), "charter-mcp-")), "auth.json")); +} + +async function freePort(): Promise { + const s = createServer(); + await new Promise((r) => s.listen(0, "127.0.0.1", r)); + const { port } = s.address() as AddressInfo; + await new Promise((r) => s.close(() => r())); + return port; +} + +describe("AuthStore", () => { + it("round-trips, merges updates, and clears selected keys", async () => { + const store = tempStore(); + expect(await store.read()).toEqual({}); + await store.update({ state: "s1" }); + await store.update({ codeVerifier: "v1" }); + expect(await store.read()).toEqual({ state: "s1", codeVerifier: "v1" }); + await store.clear(["state"]); + expect(await store.read()).toEqual({ codeVerifier: "v1" }); + }); + + it("treats a corrupt file as empty", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "charter-mcp-")); + const file = path.join(dir, "auth.json"); + const store = new AuthStore(file); + await store.update({ state: "x" }); + const { writeFileSync } = await import("node:fs"); + writeFileSync(file, "{not json"); + expect(await store.read()).toEqual({}); + }); +}); + +describe("listenForAuthorizationCode", () => { + it("resolves with the code when the state matches", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good" }); + const res = await fetch(`http://127.0.0.1:${port}/callback?code=abc&state=good`); + expect(res.status).toBe(200); + await expect(handle.code).resolves.toBe("abc"); + }); + + it("refuses a redirect with the wrong state", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good" }); + const res = await fetch(`http://127.0.0.1:${port}/callback?code=abc&state=evil`); + expect(res.status).toBe(400); + await expect(handle.code).rejects.toThrow(/state/); + }); + + it("refuses a redirect with no state when one is expected", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good" }); + await fetch(`http://127.0.0.1:${port}/callback?code=abc`); + await expect(handle.code).rejects.toThrow(/state/); + }); + + it("rejects when the server returns an error", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good" }); + await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=nope`); + await expect(handle.code).rejects.toThrow(/access_denied/); + }); + + it("times out when nothing comes back", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good", timeoutMs: 50 }); + await expect(handle.code).rejects.toThrow(/No authorization/); + }); + + it("ignores other paths", async () => { + const port = await freePort(); + const handle = await listenForAuthorizationCode({ port, expectedState: async () => "good" }); + const res = await fetch(`http://127.0.0.1:${port}/other`); + expect(res.status).toBe(404); + handle.close(); + }); +}); + +describe("client metadata document", () => { + const doc = JSON.parse(readFileSync(path.join(__dirname, "..", "docs", "mcp-client.json"), "utf-8")); + + it("is a public authorization_code client with a loopback redirect", () => { + expect(doc.token_endpoint_auth_method).toBe("none"); + expect(doc.grant_types).toEqual(["authorization_code"]); + expect(doc.response_types).toEqual(["code"]); + for (const uri of doc.redirect_uris as string[]) expect(new URL(uri).hostname).toBe("127.0.0.1"); + }); + + it("uses its own hosted URL as the client_id, and that URL is https", () => { + expect(doc.client_id.startsWith("https://")).toBe(true); + }); +}); + +describe("OAuth against a fake authorization server", () => { + let server: Server | undefined; + afterEach(() => new Promise((r) => (server ? server.close(() => r()) : r()))); + + interface Seen { + clientId?: string; + challenge?: string; + verifier?: string; + redirectUri?: string; + } + + async function startFake(seen: Seen): Promise { + server = createServer(async (req, res) => { + const base = `http://127.0.0.1:${(server!.address() as AddressInfo).port}`; + const url = new URL(req.url ?? "/", base); + const json = (body: unknown) => res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body)); + + if (url.pathname === "/.well-known/oauth-protected-resource") return json({ resource: `${base}/mcp`, authorization_servers: [base] }); + if (url.pathname === "/.well-known/oauth-authorization-server") { + return json({ + issuer: base, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + }); + } + if (url.pathname === "/token" && req.method === "POST") { + let body = ""; + for await (const chunk of req) body += chunk; + const form = new URLSearchParams(body); + seen.verifier = form.get("code_verifier") ?? undefined; + const ok = createHash("sha256").update(seen.verifier ?? "").digest("base64url") === seen.challenge && form.get("client_id") === seen.clientId; + if (!ok) return res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "invalid_grant" })); + return json({ access_token: "tok", token_type: "Bearer", expires_in: 3600 }); + } + res.writeHead(404).end(); + }); + await new Promise((r) => server!.listen(0, "127.0.0.1", r)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + } + + it("identifies by metadata URL, sends a PKCE challenge, and exchanges the code with the matching verifier", async () => { + const seen: Seen = {}; + const base = await startFake(seen); + const store = tempStore(); + let authorizeUrl: URL | undefined; + const provider = new CharterOAuthProvider({ + store, + clientMetadataUrl: CLIENT_ID_URL, + redirectUrl: "http://127.0.0.1:8976/callback", + openBrowser: (u) => { + authorizeUrl = u; + }, + }); + + const first = await auth(provider, { serverUrl: `${base}/mcp` }); + expect(first).toBe("REDIRECT"); + expect(authorizeUrl).toBeDefined(); + + const q = authorizeUrl!.searchParams; + expect(q.get("client_id")).toBe(CLIENT_ID_URL); + expect(q.get("response_type")).toBe("code"); + expect(q.get("code_challenge_method")).toBe("S256"); + expect(q.get("redirect_uri")).toBe("http://127.0.0.1:8976/callback"); + expect(q.get("state")).toBe((await store.read()).state); + + seen.clientId = CLIENT_ID_URL; + seen.challenge = q.get("code_challenge") ?? undefined; + + const second = await auth(provider, { serverUrl: `${base}/mcp`, authorizationCode: "the-code" }); + expect(second).toBe("AUTHORIZED"); + expect(seen.verifier).toBe((await store.read()).codeVerifier); + expect((await store.read()).tokens?.access_token).toBe("tok"); + }); + + it("probeAuth reports what the server advertises", async () => { + const seen: Seen = {}; + const base = await startFake(seen); + const stub = ((input: string | URL | Request, init?: RequestInit) => { + const u = String(input); + if (init?.method === "POST" && u.endsWith("/mcp")) { + return Promise.resolve(new Response("", { status: 401, headers: { "www-authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"` } })); + } + return fetch(input as string, init); + }) as typeof fetch; + + const p = await probeAuth(`${base}/mcp`, stub); + expect(p.status).toBe(401); + expect(p.supportsClientIdMetadataDocument).toBe(true); + expect(p.supportsDynamicRegistration).toBe(false); + expect(p.offersRefreshTokens).toBe(false); + expect(p.codeChallengeMethods).toEqual(["S256"]); + expect(p.tokenEndpoint).toBe(`${base}/token`); + }); + + it("probeAuth notes when no authentication is required", async () => { + const stub = (() => Promise.resolve(new Response("{}", { status: 200 }))) as unknown as typeof fetch; + const p = await probeAuth("http://127.0.0.1:1/mcp", stub); + expect(p.status).toBe(200); + expect(p.notes[0]).toMatch(/does not require OAuth/); + }); +});