UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

268 lines • 9.04 kB
import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import { BINDING_DOMAIN_PREFIX, Errors, HTTP_PROFILE, LnbtcError, MCP_PROFILE } from "./constants.js"; import { canonicalize, isWellFormed } from "./jcs.js"; const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; const LOWER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/; const HEX64 = /^[0-9a-f]{64}$/; // RFC 3986 URI characters (no fragment) with well-formed percent escapes. // Square brackets are accepted outside the host too, as common clients send them raw. const URI_SYNTAX = /^[A-Za-z][A-Za-z0-9+.-]*:(?:[A-Za-z0-9\-._~:/?[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/; const ABSENT_HASH = bytesToHex(sha256(Uint8Array.of(0x00))); const EXCLUDED_METADATA = new Set(["x402/payment", "progressToken"]); /** * Computes the `http:1` binding for an HTTP request. * * @param input - The actual request and the configured bound headers * @returns The binding, including the expected `PaymentRequired.resource.url` * @throws LnbtcError `invalid_exact_lnbtc_request_binding` on malformed input */ export function httpRequestBinding(input) { const params = { headers: [...input.boundHeaders] }; validateHttpParams(params); if (typeof input.method !== "string" || !TOKEN.test(input.method)) fail(); validateAbsoluteUri(input.url, true); if (input.body !== undefined && !(input.body instanceof Uint8Array)) fail(); const headers = params.headers.map(name => ({ name, valueHash: headerValueHash(input.getHeader(name)), })); const binding = { domain: `${BINDING_DOMAIN_PREFIX}${HTTP_PROFILE}`, method: input.method, url: input.url, bodyHash: bytesToHex(sha256(input.body ?? new Uint8Array())), headers, }; return { requestHash: hashBinding(binding), requestBindingProfile: HTTP_PROFILE, requestBindingParams: params, resourceUrl: input.url, }; } /** * Computes the `mcp:1` binding for an MCP `tools/call`. * * @param input - The actual tool call and the configured server and metadata names * @returns The binding * @throws LnbtcError `invalid_exact_lnbtc_request_binding` on malformed input */ export function mcpToolCallBinding(input) { const params = { server: input.server, metadata: [...input.boundMetadata] }; validateMcpParams(params); if (typeof input.name !== "string" || input.name.length === 0) fail(); const args = input.arguments === undefined ? {} : input.arguments; if (!isPlainObject(args)) fail(); const meta = input.meta === undefined ? {} : input.meta; if (!isPlainObject(meta)) fail(); const metadata = params.metadata.map(name => ({ name, valueHash: Object.prototype.hasOwnProperty.call(meta, name) ? hashPresent(new TextEncoder().encode(jcs(meta[name]))) : ABSENT_HASH, })); const binding = { domain: `${BINDING_DOMAIN_PREFIX}${MCP_PROFILE}`, server: params.server, method: "tools/call", name: input.name, arguments: args, metadata, }; return { requestHash: hashBinding(binding), requestBindingProfile: MCP_PROFILE, requestBindingParams: params, }; } /** * Validates the binding fields of a requirements `extra` object. * * @param extra - `PaymentRequirements.extra` * @returns The validated binding fields * @throws LnbtcError `invalid_exact_lnbtc_request_binding` when missing or malformed */ export function parseBindingExtra(extra) { const requestHash = extra?.requestHash; const profile = extra?.requestBindingProfile; const params = extra?.requestBindingParams; if (typeof requestHash !== "string" || !HEX64.test(requestHash)) fail(); if (!isPlainObject(params)) fail(); if (profile === HTTP_PROFILE) validateHttpParams(params); else if (profile === MCP_PROFILE) validateMcpParams(params); else fail(); return { requestHash, requestBindingProfile: profile, requestBindingParams: params, }; } /** * Compares two validated bindings: hash, profile, and JCS-equal parameters. * * @param a - First binding * @param b - Second binding * @returns Whether they are equal */ export function bindingsEqual(a, b) { return (a.requestHash === b.requestHash && a.requestBindingProfile === b.requestBindingProfile && jcs(a.requestBindingParams) === jcs(b.requestBindingParams)); } /** * Validates `http:1` parameters: exactly `headers`, lowercase tokens, strictly * ascending, excluding `payment-signature`. * * @param params - Candidate parameters */ function validateHttpParams(params) { if (Object.keys(params).length !== 1 || !Array.isArray(params.headers)) fail(); validateSortedNames(params.headers, name => LOWER_TOKEN.test(name)); if (params.headers.includes("payment-signature")) fail(); } /** * Validates `mcp:1` parameters: exactly `server` and `metadata`. * * @param params - Candidate parameters */ function validateMcpParams(params) { const keys = Object.keys(params).sort(); if (keys.length !== 2 || keys[0] !== "metadata" || keys[1] !== "server") fail(); if (typeof params.server !== "string") fail(); validateAbsoluteUri(params.server, false); if (!Array.isArray(params.metadata)) fail(); validateSortedNames(params.metadata, name => name.length > 0 && isWellFormed(name) && !EXCLUDED_METADATA.has(name)); } /** * Requires a strictly ascending list of valid names. * * @param names - Candidate names * @param valid - Per-name validity check */ function validateSortedNames(names, valid) { for (let i = 0; i < names.length; i++) { const name = names[i]; if (typeof name !== "string" || !valid(name)) fail(); // JS string comparison orders by UTF-16 code units: ASCII order for header // names, JCS property order for metadata names. if (i > 0 && names[i - 1] >= name) fail(); } } /** * Validates an absolute URI in RFC 3986 ASCII syntax with no userinfo or * fragment, without normalizing it. `http` and `https` URIs need an authority * with a host (`scheme://host...`). * * @param uri - Candidate URI * @param httpOnly - Require the `http` or `https` scheme */ function validateAbsoluteUri(uri, httpOnly) { if (typeof uri !== "string" || !URI_SYNTAX.test(uri)) fail(); let parsed; try { parsed = new URL(uri); } catch { fail(); } if (parsed.username || parsed.password || /^[a-z][a-z0-9+.-]*:\/\/[^/?]*@/i.test(uri)) fail(); const isHttp = parsed.protocol === "http:" || parsed.protocol === "https:"; if (httpOnly && !isHttp) fail(); if (isHttp && (!/^https?:\/\/[^/?]/i.test(uri) || parsed.hostname === "")) fail(); } /** * Hashes a header per RFC 9421 section 2.1 default field-component rules. * * @param value - Header value(s), or `undefined` when absent * @returns The value hash, lowercase hex */ function headerValueHash(value) { // No field lines (undefined or an empty list) means the header is absent. if (value === undefined || (Array.isArray(value) && value.length === 0)) return ABSENT_HASH; const lines = typeof value === "string" ? [value] : value; const parts = lines.map(line => { if (typeof line !== "string" || !/^[\t\x20-\x7e]*$/.test(line)) fail(); return line.replace(/^[\t ]+|[\t ]+$/g, ""); }); return hashPresent(new TextEncoder().encode(parts.join(", "))); } /** * Hashes a present value as SHA-256 of `0x01 || bytes`. * * @param bytes - Value bytes * @returns Lowercase hex digest */ function hashPresent(bytes) { const input = new Uint8Array(bytes.length + 1); input[0] = 0x01; input.set(bytes, 1); return bytesToHex(sha256(input)); } /** * Hashes a binding object: SHA-256 of UTF-8 JCS. * * @param binding - The binding object * @returns Lowercase hex request hash */ function hashBinding(binding) { return bytesToHex(sha256(new TextEncoder().encode(jcs(binding)))); } /** * Canonicalizes, mapping serialization errors to the binding error. * * @param value - Value to canonicalize * @returns JCS text */ function jcs(value) { try { return canonicalize(value); } catch { fail(); } } /** * Checks for a plain (non-array, non-null) object. * * @param value - Candidate value * @returns Whether it is a plain object */ function isPlainObject(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } /** * Throws the request-binding error. * * @returns Never returns */ function fail() { throw new LnbtcError(Errors.requestBinding); } //# sourceMappingURL=binding.js.map