@x207/mcp 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,31 @@
1
+ // Configuration of the x207 MCP server (SD-081). Everything comes from the environment; nothing here is a secret, and the read tools never need a key.
2
+ import { RPC_URLS } from "@x207/core";
3
+ /** Tried in order: a public endpoint that answers 429 hands over to the next. */
4
+ export const TESTNET_RPC_URLS = ["https://rpc.testnet.fastnear.com", "https://rpc.testnet.near.org"];
5
+ const ACCOUNT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
6
+ const CIRCLE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
7
+ export class ConfigError extends Error {
8
+ }
9
+ /** `X207_CIRCLE_CONTRACT` (required), `X207_CIRCLE_ID` (default `agents-main`), `X207_RPC_URL` (comma separated; default by network), `X207_BACKEND_URL` (optional), `X207_TIMEOUT_MS`. */
10
+ export function configFromEnv(env = process.env) {
11
+ const circleContract = env.X207_CIRCLE_CONTRACT?.trim() ?? "";
12
+ if (!ACCOUNT.test(circleContract))
13
+ throw new ConfigError("X207_CIRCLE_CONTRACT must be the circle contract account, e.g. x207-v013.testnet");
14
+ const circleId = env.X207_CIRCLE_ID?.trim() || "agents-main";
15
+ if (!CIRCLE_ID.test(circleId))
16
+ throw new ConfigError("X207_CIRCLE_ID must be a circle id such as agents-main");
17
+ const explicit = (env.X207_RPC_URL ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
18
+ for (const url of explicit)
19
+ if (!/^https?:\/\/[^\s]+$/.test(url))
20
+ throw new ConfigError(`X207_RPC_URL: ${JSON.stringify(url)} is not an http(s) URL`);
21
+ // a testnet circle must not read from mainnet endpoints by default
22
+ const rpcUrls = explicit.length > 0 ? explicit : circleContract.endsWith(".testnet") ? [...TESTNET_RPC_URLS] : [...RPC_URLS];
23
+ const backend = env.X207_BACKEND_URL?.trim().replace(/\/+$/, "") ?? "";
24
+ if (backend !== "" && !/^https?:\/\/[^\s]+$/.test(backend))
25
+ throw new ConfigError("X207_BACKEND_URL must be an http(s) URL");
26
+ const timeoutMs = env.X207_TIMEOUT_MS === undefined ? 10_000 : Number(env.X207_TIMEOUT_MS);
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000)
28
+ throw new ConfigError("X207_TIMEOUT_MS must be an integer from 100 to 120000");
29
+ return { circleContract, circleId, rpcUrls, backendUrl: backend === "" ? null : backend, timeoutMs };
30
+ }
31
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,25 @@
1
+ import type { CircleViewFn } from "@x207/core";
2
+ import type { WriteConfig } from "./write-config.js";
3
+ export type NearKeyKind = "none" | "function-call" | "full-access" | "unknown";
4
+ export declare class Guards {
5
+ #private;
6
+ readonly cfg: WriteConfig;
7
+ private readonly view;
8
+ constructor(cfg: WriteConfig, view: CircleViewFn);
9
+ /** Total committed in this session (credit units). */
10
+ get spent(): bigint;
11
+ get remaining(): bigint;
12
+ /** Guard 2: the price of ONE payment, offer or milestone. */
13
+ price(amount: bigint, what: string): void;
14
+ /** Guard 1: the budget of the session. Adds `amount` when it fits; call it right before the commitment is signed. */
15
+ spend(amount: bigint, what: string): void;
16
+ /** Gives back what `spend` took when the commitment was not made after all. */
17
+ release(amount: bigint): void;
18
+ /** Guard 4: only an ACTIVE member of the circle, and, when the operator wrote a list, only a member of that list. Never the agent itself. */
19
+ counterparty(account: string): Promise<void>;
20
+ /** Guard 3: tools that move USDT or NEAR out of the agent into the circle stay off unless the operator turns them on. */
21
+ outflows(tool: string): void;
22
+ /** Guard 5: what the NEAR key may do. A function-call key cannot attach a deposit, so no tool that sends one can run with it. */
23
+ nearKey(kind: NearKeyKind, tool: string): void;
24
+ }
25
+ //# sourceMappingURL=guards.d.ts.map
package/dist/guards.js ADDED
@@ -0,0 +1,67 @@
1
+ import { ToolError } from "./tools.js";
2
+ export class Guards {
3
+ cfg;
4
+ view;
5
+ #spent = 0n;
6
+ constructor(cfg, view) {
7
+ this.cfg = cfg;
8
+ this.view = view;
9
+ }
10
+ /** Total committed in this session (credit units). */
11
+ get spent() {
12
+ return this.#spent;
13
+ }
14
+ get remaining() {
15
+ return this.cfg.maxSpendSession - this.#spent;
16
+ }
17
+ /** Guard 2: the price of ONE payment, offer or milestone. */
18
+ price(amount, what) {
19
+ if (amount < 0n)
20
+ throw new ToolError(`refused: ${what} is negative`); // cross review UM-112, F4
21
+ if (amount > this.cfg.maxPricePerCall)
22
+ throw new ToolError(`refused: ${what} of ${amount} is above X207_MAX_PRICE_PER_CALL (${this.cfg.maxPricePerCall}); the price the seller names is not trusted`);
23
+ }
24
+ /** Guard 1: the budget of the session. Adds `amount` when it fits; call it right before the commitment is signed. */
25
+ spend(amount, what) {
26
+ if (amount < 0n)
27
+ throw new ToolError(`refused: ${what} is negative`);
28
+ if (this.#spent + amount > this.cfg.maxSpendSession) {
29
+ throw new ToolError(`refused: ${what} of ${amount} would take this session to ${this.#spent + amount}, above X207_MAX_SPEND_SESSION (${this.cfg.maxSpendSession}); ${this.remaining} left`);
30
+ }
31
+ this.#spent += amount;
32
+ }
33
+ /** Gives back what `spend` took when the commitment was not made after all. */
34
+ release(amount) {
35
+ if (amount < 0n)
36
+ return;
37
+ this.#spent = this.#spent >= amount ? this.#spent - amount : 0n;
38
+ }
39
+ /** Guard 4: only an ACTIVE member of the circle, and, when the operator wrote a list, only a member of that list. Never the agent itself. */
40
+ async counterparty(account) {
41
+ if (account === this.cfg.account)
42
+ throw new ToolError("refused: the agent cannot deal with itself");
43
+ const list = this.cfg.allowedCounterparties;
44
+ if (list.length > 0 && !list.includes(account))
45
+ throw new ToolError(`refused: ${account} is not in X207_ALLOWED_COUNTERPARTIES`);
46
+ const member = (await this.view("member", { account_id: account }));
47
+ if (member === null || member.status !== "active")
48
+ throw new ToolError(`refused: ${account} is not an active member of the circle`);
49
+ }
50
+ /** Guard 3: tools that move USDT or NEAR out of the agent into the circle stay off unless the operator turns them on. */
51
+ outflows(tool) {
52
+ if (!this.cfg.allowOutflows)
53
+ throw new ToolError(`refused: ${tool} moves funds out of the agent account; set X207_ALLOW_OUTFLOWS=1 to allow it`);
54
+ }
55
+ /** Guard 5: what the NEAR key may do. A function-call key cannot attach a deposit, so no tool that sends one can run with it. */
56
+ nearKey(kind, tool) {
57
+ if (this.cfg.nearKey === null)
58
+ throw new ToolError(`refused: ${tool} sends a transaction and needs X207_AGENT_NEAR_KEY`);
59
+ if (kind === "function-call")
60
+ throw new ToolError(`refused: the NEAR key is a function-call key, which cannot attach the deposit that ${tool} needs; no transaction was sent`);
61
+ if (kind === "full-access" && !this.cfg.allowFullAccessKey)
62
+ throw new ToolError(`refused: the NEAR key has full access to the account; set X207_ALLOW_FULL_ACCESS_KEY=1 to use it for ${tool}`);
63
+ if (kind !== "full-access")
64
+ throw new ToolError(`refused: the NEAR key is not a key of ${this.cfg.account}; no transaction was sent`);
65
+ }
66
+ }
67
+ //# sourceMappingURL=guards.js.map
package/dist/http.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type Server } from "node:http";
2
+ import type { McpConfig } from "./config.js";
3
+ export interface HttpOptions {
4
+ host?: string;
5
+ port?: number;
6
+ /** Largest request body in bytes; default 1 MiB. */
7
+ maxBodyBytes?: number;
8
+ /** Values of the `Host` header that are served, e.g. `["mcp.example.org"]`. On a loopback address the default is `127.0.0.1`, `localhost` and `[::1]` (with the port). */
9
+ allowedHosts?: readonly string[];
10
+ }
11
+ /** Starts the server; resolves when it listens. `GET /healthz` answers 200, `POST /mcp` is the MCP endpoint, everything else is refused. */
12
+ /** A loopback bind: `localhost`, any 127.x.x.x, `::1` or the mapped form of a 127.x address (cross review UM-112, F7: `127.0.0.2` is loopback too, and must not serve every Host). */
13
+ export declare function isLoopbackBind(bind: string): boolean;
14
+ export declare function startHttpServer(config: McpConfig, options?: HttpOptions): Promise<{
15
+ server: Server;
16
+ url: string;
17
+ close: () => Promise<void>;
18
+ }>;
19
+ //# sourceMappingURL=http.d.ts.map
package/dist/http.js ADDED
@@ -0,0 +1,94 @@
1
+ // Streamable HTTP transport (SD-081): stateless, one server per request, read tools only. Binds to 127.0.0.1 unless told otherwise; the server has no key and signs nothing, so what it
2
+ // shows is what the contract and the read API already show to anybody.
3
+ import { isIP } from "node:net";
4
+ import { createServer } from "node:http";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { ipv6Bytes } from "./net-guard.js";
7
+ import { createX207McpServer } from "./server.js";
8
+ const json = (res, status, body, extra = {}) => {
9
+ res.writeHead(status, { "content-type": "application/json", ...extra });
10
+ res.end(JSON.stringify(body));
11
+ };
12
+ async function readBody(req, max) {
13
+ const chunks = [];
14
+ let size = 0;
15
+ for await (const chunk of req) {
16
+ size += chunk.length;
17
+ if (size > max)
18
+ throw Object.assign(new Error("body too large"), { status: 413 });
19
+ chunks.push(chunk);
20
+ }
21
+ try {
22
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
23
+ }
24
+ catch {
25
+ throw Object.assign(new Error("body is not JSON"), { status: 400 });
26
+ }
27
+ }
28
+ /** Starts the server; resolves when it listens. `GET /healthz` answers 200, `POST /mcp` is the MCP endpoint, everything else is refused. */
29
+ /** A loopback bind: `localhost`, any 127.x.x.x, `::1` or the mapped form of a 127.x address (cross review UM-112, F7: `127.0.0.2` is loopback too, and must not serve every Host). */
30
+ export function isLoopbackBind(bind) {
31
+ const host = bind.replace(/^\[|\]$/g, "").toLowerCase();
32
+ if (host === "localhost" || host === "::1")
33
+ return true;
34
+ if (isIP(host) === 4)
35
+ return host.startsWith("127.");
36
+ const bytes = ipv6Bytes(host);
37
+ return bytes !== null && bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 0xff && bytes[11] === 0xff && bytes[12] === 127;
38
+ }
39
+ export function startHttpServer(config, options = {}) {
40
+ const maxBody = options.maxBodyBytes ?? 1_048_576;
41
+ const bind = options.host ?? "127.0.0.1";
42
+ const loopback = isLoopbackBind(bind);
43
+ // DNS rebinding (security review batch 8, SD-081 fix): a web page can make a browser send a request to 127.0.0.1 under the name of an attacker's site. The browser puts that name in `Host`,
44
+ // so on a loopback address only the names of the loopback are served. A server that the operator binds to another address serves what `allowedHosts` says, or every host when there is no list.
45
+ const hostAllowed = (header, port) => {
46
+ if (header === undefined)
47
+ return !loopback && options.allowedHosts === undefined;
48
+ const name = header.toLowerCase();
49
+ if (options.allowedHosts !== undefined)
50
+ return options.allowedHosts.map((h) => h.toLowerCase()).includes(name);
51
+ if (!loopback)
52
+ return true;
53
+ return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, "127.0.0.1", "localhost", "[::1]", bind.toLowerCase(), `${bind.toLowerCase()}:${port}`, `[${bind.toLowerCase()}]:${port}`].includes(name);
54
+ };
55
+ const server = createServer(async (req, res) => {
56
+ try {
57
+ const port = (req.socket.localPort ?? options.port ?? 0);
58
+ if (!hostAllowed(req.headers.host, port))
59
+ return json(res, 403, { error: "host_not_allowed", message: "the Host header is not one that this server serves" });
60
+ const path = (req.url ?? "").split("?")[0];
61
+ if (path === "/healthz" && req.method === "GET")
62
+ return json(res, 200, { ok: true, name: "x207-mcp" });
63
+ if (path !== "/mcp")
64
+ return json(res, 404, { error: "not_found" });
65
+ if (req.method !== "POST")
66
+ return json(res, 405, { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed: this server is stateless, use POST /mcp" }, id: null }, { allow: "POST" });
67
+ const body = await readBody(req, maxBody);
68
+ const mcp = createX207McpServer(config, "http");
69
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
70
+ res.on("close", () => {
71
+ void transport.close();
72
+ void mcp.close();
73
+ });
74
+ await mcp.connect(transport);
75
+ await transport.handleRequest(req, res, body);
76
+ }
77
+ catch (error) {
78
+ const status = error.status ?? 500;
79
+ if (!res.headersSent)
80
+ json(res, status, { jsonrpc: "2.0", error: { code: status === 500 ? -32603 : -32600, message: status === 500 ? "internal error" : error.message }, id: null });
81
+ else
82
+ res.end();
83
+ }
84
+ });
85
+ return new Promise((resolve, reject) => {
86
+ server.once("error", reject);
87
+ server.listen(options.port ?? 3207, options.host ?? "127.0.0.1", () => {
88
+ const address = server.address();
89
+ const port = typeof address === "object" && address ? address.port : options.port;
90
+ resolve({ server, url: `http://${options.host ?? "127.0.0.1"}:${port}/mcp`, close: () => new Promise((r) => (server.closeAllConnections?.(), server.close(() => r()))) });
91
+ });
92
+ });
93
+ }
94
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,10 @@
1
+ export * from "./config.js";
2
+ export * from "./backend.js";
3
+ export * from "./tools.js";
4
+ export * from "./server.js";
5
+ export * from "./http.js";
6
+ export * from "./write-config.js";
7
+ export * from "./guards.js";
8
+ export * from "./write-tools.js";
9
+ export * from "./net-guard.js";
10
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // @x207/mcp: the MCP server of x207 (SD-081, read tools).
2
+ export * from "./config.js";
3
+ export * from "./backend.js";
4
+ export * from "./tools.js";
5
+ export * from "./server.js";
6
+ export * from "./http.js";
7
+ export * from "./write-config.js";
8
+ export * from "./guards.js";
9
+ export * from "./write-tools.js";
10
+ export * from "./net-guard.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ export type HostResolver = (host: string) => Promise<string[]>;
2
+ export declare const defaultResolver: HostResolver;
3
+ /** The 16 bytes of a literal IPv6 address (`::` forms, an IPv4 tail); `null` when it is not one. */
4
+ export declare function ipv6Bytes(address: string): number[] | null;
5
+ /** `true` when `address` (a literal IPv4 or IPv6) is not a public unicast address. */
6
+ export declare function isPrivateAddress(address: string): boolean;
7
+ /** Throws a message for the model when the URL points at a place that a paid request should not reach. */
8
+ export declare function assertPublicUrl(url: URL, resolve?: HostResolver): Promise<void>;
9
+ /** Reads a response body up to `limit` bytes and stops there: a seller that streams for ever cannot fill the memory of the server. */
10
+ export declare function readCapped(response: Response, limit: number): Promise<{
11
+ text: string;
12
+ cut: boolean;
13
+ }>;
14
+ /**
15
+ * M-2 (cross review UM-112): DNS rebinding. `assertPublicUrl` resolves the name to check it, and `fetch` resolves it again to connect, so a name that answers a public address to the first
16
+ * question and 169.254.169.254 to the second walks around the check. This fetch connects through its own `lookup`: the name is resolved ONCE, every address is checked, and the connection
17
+ * goes to one of the addresses that were checked (the TLS name stays the host of the URL). No redirect is followed (the caller sees the 3xx), and the body is read as a stream.
18
+ */
19
+ export declare function createPinnedFetch(resolve?: HostResolver): typeof fetch;
20
+ //# sourceMappingURL=net-guard.d.ts.map
@@ -0,0 +1,182 @@
1
+ // The network guard of x207_pay_request (security review batch 8, SD-082 fix). The URL comes from a model, so it can come from a prompt injection: without a guard the tool would fetch
2
+ // http://169.254.169.254/ (the credentials of a cloud machine), http://127.0.0.1:<port>/ (a service on this machine) or an address of the office network, and hand the answer back to the model.
3
+ // A URL is refused when its host is, or resolves to, a loopback, private, link-local, carrier-grade NAT, multicast or unspecified address, or when it is a name like `localhost` or `*.internal`.
4
+ // X207_ALLOW_PRIVATE_URLS=1 turns the guard off, for a seller on this machine (the demos and the sandbox tests do that).
5
+ import { lookup } from "node:dns/promises";
6
+ import { request as httpRequest } from "node:http";
7
+ import { request as httpsRequest } from "node:https";
8
+ import { isIP } from "node:net";
9
+ import { Readable } from "node:stream";
10
+ export const defaultResolver = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
11
+ const NAMES = /(^|\.)(localhost|local|internal|localdomain|home\.arpa)$/i;
12
+ /** The 16 bytes of a literal IPv6 address (`::` forms, an IPv4 tail); `null` when it is not one. */
13
+ export function ipv6Bytes(address) {
14
+ let text = address.toLowerCase();
15
+ const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
16
+ if (tail) {
17
+ const q = tail.slice(1).map(Number);
18
+ if (q.some((x) => x > 255))
19
+ return null;
20
+ text = text.slice(0, tail.index) + ((q[0] << 8) | q[1]).toString(16) + ":" + ((q[2] << 8) | q[3]).toString(16);
21
+ }
22
+ const halves = text.split("::");
23
+ if (halves.length > 2)
24
+ return null;
25
+ const groups = (part) => (part === "" ? [] : part.split(":"));
26
+ const head = groups(halves[0]);
27
+ const rest = halves.length === 2 ? groups(halves[1]) : [];
28
+ const missing = 8 - head.length - rest.length;
29
+ if (halves.length === 1 ? head.length !== 8 : missing < 1)
30
+ return null;
31
+ const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
32
+ const out = [];
33
+ for (const g of all) {
34
+ if (!/^[0-9a-f]{1,4}$/.test(g))
35
+ return null;
36
+ const v = parseInt(g, 16);
37
+ out.push(v >> 8, v & 255);
38
+ }
39
+ return out.length === 16 ? out : null;
40
+ }
41
+ /** `true` when `address` (a literal IPv4 or IPv6) is not a public unicast address. */
42
+ export function isPrivateAddress(address) {
43
+ const a = address.replace(/^\[|\]$/g, "").toLowerCase();
44
+ const family = isIP(a);
45
+ if (family === 4) {
46
+ const [p, q] = a.split(".").map(Number);
47
+ return p === 0 || p === 10 || p === 127 || (p === 100 && q >= 64 && q <= 127) || (p === 169 && q === 254) || (p === 172 && q >= 16 && q <= 31) || (p === 192 && q === 168) || (p === 192 && q === 0) || (p === 198 && (q === 18 || q === 19)) || p >= 224;
48
+ }
49
+ if (family === 6) {
50
+ const bytes = ipv6Bytes(a);
51
+ if (bytes === null)
52
+ return true;
53
+ const v4 = (i) => `${bytes[i]}.${bytes[i + 1]}.${bytes[i + 2]}.${bytes[i + 3]}`;
54
+ const zeros = (from, to) => bytes.slice(from, to).every((x) => x === 0);
55
+ // forms that CARRY an IPv4 address: the carried address decides (cross review UM-112, F1; `new URL` writes them in hex, `::ffff:7f00:1`, `64:ff9b::a9fe:a9fe`)
56
+ if (zeros(0, 10) && bytes[10] === 0xff && bytes[11] === 0xff)
57
+ return isPrivateAddress(v4(12)); // ::ffff:0:0/96 IPv4-mapped
58
+ if (zeros(0, 12))
59
+ return bytes[12] === 0 && bytes[13] === 0 && bytes[14] === 0 && bytes[15] <= 1 ? true : isPrivateAddress(v4(12)); // ::/96 IPv4-compatible, :: and ::1
60
+ if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b) { // 64:ff9b::/96 NAT64, and 64:ff9b:1::/48 (local use, refused whole)
61
+ if (zeros(4, 12))
62
+ return isPrivateAddress(v4(12));
63
+ if (bytes[4] === 0 && bytes[5] === 1)
64
+ return true;
65
+ }
66
+ if (zeros(0, 8) && bytes[8] === 0xff && bytes[9] === 0xff && bytes[10] === 0 && bytes[11] === 0)
67
+ return isPrivateAddress(v4(12)); // ::ffff:0:0:0/96 IPv4-translated (SIIT)
68
+ if (bytes[0] === 0x20 && bytes[1] === 0x02)
69
+ return isPrivateAddress(v4(2)); // 2002::/16 6to4
70
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0 && bytes[3] === 0)
71
+ return true; // 2001::/32 Teredo: the server and the client are inside the address
72
+ const b0 = bytes[0];
73
+ const b1 = bytes[1];
74
+ if ((b0 & 0xfe) === 0xfc)
75
+ return true; // fc00::/7 unique local
76
+ if (b0 === 0xfe && (b1 & 0xc0) === 0x80)
77
+ return true; // fe80::/10 link-local
78
+ if (b0 === 0xfe && (b1 & 0xc0) === 0xc0)
79
+ return true; // fec0::/10 site-local (deprecated)
80
+ if (bytes[0] === 0xff)
81
+ return true; // ff00::/8 multicast
82
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8)
83
+ return true; // 2001:db8::/32 documentation
84
+ return false;
85
+ }
86
+ return true; // not an address at all
87
+ }
88
+ /** Throws a message for the model when the URL points at a place that a paid request should not reach. */
89
+ export async function assertPublicUrl(url, resolve = defaultResolver) {
90
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, ""); // `localhost.` is `localhost` (cross review UM-112, F6)
91
+ if (host === "")
92
+ throw new Error("the URL has no host");
93
+ if (NAMES.test(host))
94
+ throw new Error(`refused: ${host} is a name of this machine or of a private network; set X207_ALLOW_PRIVATE_URLS=1 only for a seller you run yourself`);
95
+ if (isIP(host) !== 0) {
96
+ if (isPrivateAddress(host))
97
+ throw new Error(`refused: ${host} is a private, loopback or link-local address; set X207_ALLOW_PRIVATE_URLS=1 only for a seller you run yourself`);
98
+ return;
99
+ }
100
+ let addresses;
101
+ try {
102
+ addresses = await resolve(host);
103
+ }
104
+ catch (error) {
105
+ throw new Error(`refused: ${host} could not be resolved (${error.message})`);
106
+ }
107
+ if (addresses.length === 0)
108
+ throw new Error(`refused: ${host} has no address`);
109
+ for (const address of addresses) {
110
+ if (isPrivateAddress(address))
111
+ throw new Error(`refused: ${host} resolves to ${address}, a private, loopback or link-local address; set X207_ALLOW_PRIVATE_URLS=1 only for a seller you run yourself`);
112
+ }
113
+ }
114
+ /** Reads a response body up to `limit` bytes and stops there: a seller that streams for ever cannot fill the memory of the server. */
115
+ export async function readCapped(response, limit) {
116
+ if (response.body === null)
117
+ return { text: "", cut: false };
118
+ const reader = response.body.getReader();
119
+ const chunks = [];
120
+ let size = 0;
121
+ let cut = false;
122
+ for (;;) {
123
+ const { done, value } = await reader.read();
124
+ if (done)
125
+ break;
126
+ if (size + value.length > limit) {
127
+ chunks.push(value.subarray(0, limit - size));
128
+ size = limit;
129
+ cut = true;
130
+ await reader.cancel().catch(() => undefined);
131
+ break;
132
+ }
133
+ chunks.push(value);
134
+ size += value.length;
135
+ }
136
+ return { text: Buffer.concat(chunks).toString("utf8"), cut };
137
+ }
138
+ /**
139
+ * M-2 (cross review UM-112): DNS rebinding. `assertPublicUrl` resolves the name to check it, and `fetch` resolves it again to connect, so a name that answers a public address to the first
140
+ * question and 169.254.169.254 to the second walks around the check. This fetch connects through its own `lookup`: the name is resolved ONCE, every address is checked, and the connection
141
+ * goes to one of the addresses that were checked (the TLS name stays the host of the URL). No redirect is followed (the caller sees the 3xx), and the body is read as a stream.
142
+ */
143
+ export function createPinnedFetch(resolve = defaultResolver) {
144
+ const lookupChecked = (hostname, options, callback) => {
145
+ resolve(hostname.replace(/\.+$/, "")).then((addresses) => {
146
+ const bad = addresses.find((a) => isPrivateAddress(a));
147
+ if (addresses.length === 0 || bad !== undefined)
148
+ return callback(new Error(`refused: ${hostname} resolves to ${bad ?? "no address"}, a private, loopback or link-local address`));
149
+ const list = addresses.map((address) => ({ address, family: isIP(address) }));
150
+ return options.all === true ? callback(null, list) : callback(null, list[0].address, list[0].family);
151
+ }, (error) => callback(error));
152
+ };
153
+ return (async (input, init) => {
154
+ const req = input instanceof Request ? input : new Request(input, init);
155
+ const url = new URL(req.url);
156
+ if (url.protocol !== "http:" && url.protocol !== "https:")
157
+ throw new Error("only http and https");
158
+ if (init?.redirect !== undefined && init.redirect !== "manual" && req.redirect !== "manual")
159
+ throw new Error("a redirect is not followed by this fetch");
160
+ const body = req.body === null ? null : Buffer.from(await req.arrayBuffer());
161
+ const headers = {};
162
+ req.headers.forEach((value, name) => {
163
+ headers[name] = value;
164
+ });
165
+ if (body !== null)
166
+ headers["content-length"] = String(body.length);
167
+ return new Promise((resolveResponse, reject) => {
168
+ const send = url.protocol === "https:" ? httpsRequest : httpRequest;
169
+ const out = send(url, { method: req.method, headers, lookup: lookupChecked, signal: req.signal }, (res) => {
170
+ const responseHeaders = new Headers();
171
+ for (const [name, value] of Object.entries(res.headers))
172
+ for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value])
173
+ responseHeaders.append(name, v);
174
+ const status = res.statusCode ?? 502;
175
+ resolveResponse(new Response(status === 204 || status === 304 ? null : Readable.toWeb(res), { status, statusText: res.statusMessage ?? "", headers: responseHeaders }));
176
+ });
177
+ out.on("error", reject);
178
+ out.end(body ?? undefined);
179
+ });
180
+ });
181
+ }
182
+ //# sourceMappingURL=net-guard.js.map
@@ -0,0 +1,24 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { McpConfig } from "./config.js";
3
+ import { type ToolContext, type ToolDef } from "./tools.js";
4
+ import { type WriteConfig } from "./write-config.js";
5
+ import type { ContractCall } from "@x207/core";
6
+ export type Transport = "stdio" | "http";
7
+ /** Longest text of one tool result. A longer answer is cut and says so, so one call cannot fill the context of a model. */
8
+ export declare const MAX_RESULT_CHARS = 100000;
9
+ /** The tools that run on a transport: HTTP only ever gets the read tools. */
10
+ export declare const toolsFor: (transport: Transport) => ToolDef[];
11
+ export declare function contextFrom(config: McpConfig): ToolContext;
12
+ export interface ServerOptions {
13
+ version?: string;
14
+ /** The write configuration (SD-082). Honoured on stdio only: on HTTP it is ignored and the server stays read-only. */
15
+ write?: WriteConfig | null;
16
+ /** Replaces the NEAR sender of the write tools (tests). */
17
+ send?: (call: ContractCall) => Promise<unknown>;
18
+ now?: () => number;
19
+ /** Replaces the DNS lookup of the SSRF guard (tests). */
20
+ resolve?: (host: string) => Promise<string[]>;
21
+ }
22
+ /** One MCP server with the tools of `transport`. The read tools read views of the contract and the read API and send nothing. The write tools exist only on stdio with a write configuration. */
23
+ export declare function createX207McpServer(config: McpConfig, transport: Transport, options?: ServerOptions): McpServer;
24
+ //# sourceMappingURL=server.d.ts.map
package/dist/server.js ADDED
@@ -0,0 +1,47 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { createCircleViewFn } from "@x207/core";
3
+ import { createBackendGet, createBackendPost } from "./backend.js";
4
+ import { READ_TOOLS, ToolError } from "./tools.js";
5
+ import { availableWriteTools, createWriteRuntime } from "./write-tools.js";
6
+ import { scrub } from "./write-config.js";
7
+ /** Longest text of one tool result. A longer answer is cut and says so, so one call cannot fill the context of a model. */
8
+ export const MAX_RESULT_CHARS = 100_000;
9
+ const text = (value) => {
10
+ const out = JSON.stringify(value, null, 2) ?? "null";
11
+ return out.length <= MAX_RESULT_CHARS ? out : `${out.slice(0, MAX_RESULT_CHARS)}\n… cut at ${MAX_RESULT_CHARS} characters; ask for fewer items or use the cursor`;
12
+ };
13
+ /** The tools that run on a transport: HTTP only ever gets the read tools. */
14
+ export const toolsFor = (transport) => READ_TOOLS.filter((t) => transport === "stdio" || t.access === "read");
15
+ export function contextFrom(config) {
16
+ const view = createCircleViewFn({ circleContract: config.circleContract, rpcUrls: config.rpcUrls, timeoutMs: config.timeoutMs, ...(config.fetch ? { fetch: config.fetch } : {}) });
17
+ const backend = config.backendUrl === null ? null : createBackendGet({ baseUrl: config.backendUrl, circleId: config.circleId, timeoutMs: config.timeoutMs, ...(config.fetch ? { fetch: config.fetch } : {}) });
18
+ return { view, backend, circleContract: config.circleContract, circleId: config.circleId };
19
+ }
20
+ /** One MCP server with the tools of `transport`. The read tools read views of the contract and the read API and send nothing. The write tools exist only on stdio with a write configuration. */
21
+ export function createX207McpServer(config, transport, options = {}) {
22
+ const server = new McpServer({ name: "x207", version: options.version ?? "0.0.1" });
23
+ const ctx = contextFrom(config);
24
+ const writeCfg = transport === "stdio" ? (options.write ?? null) : null;
25
+ let tools = toolsFor(transport);
26
+ if (writeCfg !== null) {
27
+ const post = config.backendUrl === null ? null : createBackendPost({ baseUrl: config.backendUrl, circleId: config.circleId, timeoutMs: config.timeoutMs, ...(config.fetch ? { fetch: config.fetch } : {}) });
28
+ ctx.write = createWriteRuntime(config, writeCfg, ctx.view, { post, ...(options.send ? { send: options.send } : {}), ...(options.now ? { now: options.now } : {}), ...(options.resolve ? { resolve: options.resolve } : {}) });
29
+ tools = [...tools, ...availableWriteTools(ctx.write, ctx.backend !== null)];
30
+ }
31
+ for (const tool of tools) {
32
+ // `as never` on inputSchema: this package builds its schemas with zod 4, and which copy of zod the MCP SDK resolves depends on the install (the sdk repo alone hoists zod 4, the umbrella
33
+ // workspace hoists zod 3.25 to the root and the SDK resolves that one), so the two declarations of the schema type differ for tsc while the SDK reads either at run time (SD-084).
34
+ server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.inputSchema, annotations: { readOnlyHint: tool.access === "read", destructiveHint: tool.access === "write", idempotentHint: tool.access === "read", openWorldHint: true } }, (async (args) => {
35
+ try {
36
+ return { content: [{ type: "text", text: scrub(text(await tool.handler(args, ctx)), writeCfg) }] };
37
+ }
38
+ catch (error) {
39
+ // a tool error is an answer for the model, not a crash of the server; no key can be in it
40
+ const message = error instanceof ToolError ? error.message : `${tool.name} failed: ${error.message}`;
41
+ return { isError: true, content: [{ type: "text", text: scrub(message, writeCfg) }] };
42
+ }
43
+ }));
44
+ }
45
+ return server;
46
+ }
47
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,27 @@
1
+ import type { CircleViewFn } from "@x207/core";
2
+ import { z } from "zod";
3
+ import { type BackendGet } from "./backend.js";
4
+ import type { WriteRuntime } from "./write-tools.js";
5
+ export interface ToolContext {
6
+ view: CircleViewFn;
7
+ /** `null` when no read API is configured. */
8
+ backend: BackendGet | null;
9
+ circleContract: string;
10
+ circleId: string;
11
+ /** Present only on stdio with X207_MCP_WRITE=1 (SD-082). */
12
+ write?: WriteRuntime;
13
+ }
14
+ export interface ToolDef<Shape extends z.ZodRawShape = z.ZodRawShape> {
15
+ name: string;
16
+ title: string;
17
+ description: string;
18
+ /** `read` tools run on every transport; a `write` tool would run on stdio only (SD-082). */
19
+ access: "read" | "write";
20
+ inputSchema: Shape;
21
+ handler: (args: z.infer<z.ZodObject<Shape>>, ctx: ToolContext) => Promise<unknown>;
22
+ }
23
+ /** What a tool says when it cannot do its job: shown to the model as the text of an error result. */
24
+ export declare class ToolError extends Error {
25
+ }
26
+ export declare const READ_TOOLS: ToolDef[];
27
+ //# sourceMappingURL=tools.d.ts.map