Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ data/mandates/
data/mcp-token.json
*.log
.DS_Store
data/mcp-auth.json
data/mcp-tools.json
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/mcp-client.json
Original file line number Diff line number Diff line change
@@ -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"
}
73 changes: 73 additions & 0 deletions src/cli/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<string, unknown>; 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<void> {
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)"}`);
}
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/mcp/authStore.ts
Original file line number Diff line number Diff line change
@@ -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<StoredAuth> {
if (!existsSync(this.filePath)) return {};
try {
return JSON.parse(await readFile(this.filePath, "utf-8")) as StoredAuth;
} catch {
return {};
}
}

async update(patch: Partial<StoredAuth>): Promise<void> {
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<keyof StoredAuth>): Promise<void> {
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 });
}
}
77 changes: 77 additions & 0 deletions src/mcp/loopback.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<string | undefined>; timeoutMs?: number }): Promise<LoopbackHandle> {
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<string>((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() }));
});
}
Loading
Loading