@toolstop/check-digits
Advanced tools
@@ -22,3 +22,7 @@ // Stateless MCP over HTTP, for Cloudflare Workers. | ||
| function validate(schema, args) { | ||
| // Exported so the shared suite can point the same rules at a tool's declared | ||
| // outputSchema. A tool that advertises a schema and then returns something else | ||
| // is a defect no client can defend against, and checking it with the transport's | ||
| // own validator means the check cannot drift from what the transport enforces. | ||
| export function validate(schema, args) { | ||
| const errors = []; | ||
@@ -198,2 +202,44 @@ const props = schema?.properties ?? {}; | ||
| // ----------------------------------------------------------------- landing page | ||
| const escapeHtml = (s) => | ||
| String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); | ||
| // What a human sees when they paste the hostname into a browser. Worth having | ||
| // because the endpoint is otherwise indistinguishable from a broken host: the | ||
| // only other thing a GET could return is an error, and someone evaluating an | ||
| // unknown vendor reads that as "this does not work" rather than "wrong method". | ||
| function landingPage(server, origin) { | ||
| const npm = `https://www.npmjs.com/package/@toolstop/${server.name}`; | ||
| const tools = (server.tools ?? []) | ||
| .map((t) => `<li><code>${escapeHtml(t.name)}</code> ${escapeHtml(t.title ?? "")}</li>`) | ||
| .join("\n"); | ||
| return `<!doctype html> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width,initial-scale=1"> | ||
| <title>${escapeHtml(server.name)} - MCP server</title> | ||
| <style> | ||
| body { max-width: 34rem; margin: 4rem auto; padding: 0 1.5rem; | ||
| font: 16px/1.6 system-ui, sans-serif; } | ||
| code { background: #8881; padding: .1em .3em; border-radius: 3px; } | ||
| ul { padding-left: 1.2rem } | ||
| @media (prefers-color-scheme: dark) { body { background: #111; color: #eee } a { color: #7bf } } | ||
| </style> | ||
| <h1>${escapeHtml(server.name)}</h1> | ||
| <p>An MCP server, version ${escapeHtml(server.version)}. This URL speaks | ||
| <a href="https://modelcontextprotocol.io">Model Context Protocol</a> over | ||
| streamable HTTP; it answers <code>POST</code>, not <code>GET</code>, so there is | ||
| nothing else to see here in a browser.</p> | ||
| <p>Add it to an MCP client as <code>${escapeHtml(origin)}</code>, or run it | ||
| locally from <a href="${escapeHtml(npm)}">npm</a>.</p> | ||
| <h2>Tools</h2> | ||
| <ul> | ||
| ${tools} | ||
| </ul> | ||
| <p><a href="https://github.com/toolstop/toolstop">Source</a> (MIT) · | ||
| <a href="/health">health</a></p> | ||
| `; | ||
| } | ||
| // ------------------------------------------------------------- fetch handler | ||
@@ -205,7 +251,27 @@ | ||
| const url = new URL(request.url); | ||
| const read = request.method === "GET" || request.method === "HEAD"; | ||
| if (request.method === "GET" && url.pathname === "/health") { | ||
| if (read && url.pathname === "/health") { | ||
| return Response.json({ ok: true, server: server.name, version: server.version }); | ||
| } | ||
| // A GET on the MCP endpoint is how a client opens the server-initiated SSE | ||
| // stream, and the spec says a server that does not offer one answers 405. | ||
| // That is a different question from "what is at this URL", which is what a | ||
| // browser is asking, and the two are told apart by Accept alone. | ||
| if (read && url.pathname === "/") { | ||
| if ((request.headers.get("accept") ?? "").includes("text/event-stream")) { | ||
| return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); | ||
| } | ||
| return new Response(landingPage(server, url.origin), { | ||
| headers: { "content-type": "text/html; charset=utf-8" }, | ||
| }); | ||
| } | ||
| // Anything else read from a path this server does not have is a 404, not a | ||
| // 405. Discovery probes land here -- `/.well-known/oauth-protected-resource` | ||
| // most consequentially, where a client reads 404 as "no OAuth required" and | ||
| // proceeds, but has no defined reading for a 405. | ||
| if (read) return new Response("Not Found", { status: 404 }); | ||
| if (request.method !== "POST") { | ||
@@ -212,0 +278,0 @@ return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); |
@@ -80,10 +80,65 @@ // Traffic recording for spray servers. | ||
| /** | ||
| * Anonymous per-connection id. Derived from coarse request properties, not from | ||
| * anything identifying, and not stable across days by design. Enough to count | ||
| * distinct sessions, not enough to track a person. | ||
| * Reduce a client address to the network it came from, before anything hashes | ||
| * it. IPv4 keeps the /24, IPv6 the /48. | ||
| * | ||
| * This is not belt-and-braces on top of the hash, it is the part that does the | ||
| * work. `sessionIdFrom` truncates SHA-256 to 8 bytes, and the input it used to | ||
| * cover was a full IP: a 32-bit space, with a salt that is the public server | ||
| * name and a date that is public. All three are guessable, so the space is | ||
| * walkable and the "anonymous" id resolved back to one address. Hashing a | ||
| * value smaller than the hash does not hide it. | ||
| * | ||
| * Truncating first changes what is recoverable from a person to a network | ||
| * block, which is the actual privacy property. A /24 is still walkable and is | ||
| * meant to be: recovering "this came from 203.0.113.0/24" identifies an ISP | ||
| * allocation, not a subscriber. | ||
| */ | ||
| export function networkOf(ip) { | ||
| if (!ip) return ""; | ||
| if (ip.includes(":")) { | ||
| // Expand `::` before truncating. Slicing the raw string is wrong twice: | ||
| // `2001:db8::1` would yield a malformed `2001:db8:::/48`, and two spellings | ||
| // of one network would hash to two different sessions. | ||
| let groups; | ||
| if (ip.includes("::")) { | ||
| const [left, right = ""] = ip.split("::"); | ||
| const l = left ? left.split(":") : []; | ||
| const r = right ? right.split(":") : []; | ||
| const gap = 8 - l.length - r.length; | ||
| if (gap < 1) return ""; | ||
| groups = [...l, ...Array(gap).fill("0"), ...r]; | ||
| } else { | ||
| groups = ip.split(":"); | ||
| } | ||
| if (groups.length !== 8) return ""; | ||
| if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return ""; | ||
| // Strip leading zeros so 2001:0db8:… and 2001:db8:… agree. | ||
| return `${groups | ||
| .slice(0, 3) | ||
| .map((g) => g.replace(/^0+/, "") || "0") | ||
| .join(":") | ||
| .toLowerCase()}::/48`; | ||
| } | ||
| const octets = ip.split("."); | ||
| if (octets.length !== 4) return ""; // not an address shape we recognise | ||
| if (!octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) return ""; | ||
| return `${octets.slice(0, 3).join(".")}.0/24`; | ||
| } | ||
| /** | ||
| * Anonymous per-connection id. Derived from the client's *network*, its user | ||
| * agent, and the date, so it counts distinct sessions within a day without | ||
| * identifying anyone, and never links across days. | ||
| * | ||
| * The cost is deliberate: two callers behind one /24 running the same client on | ||
| * the same day count as one session. Session counts are therefore a lower | ||
| * bound. That is the right direction to be wrong in for a metric nobody is | ||
| * billed on. | ||
| */ | ||
| export async function sessionIdFrom(request, salt = "") { | ||
| const parts = [ | ||
| request.headers.get("user-agent") ?? "", | ||
| request.headers.get("cf-connecting-ip") ?? "", | ||
| networkOf(request.headers.get("cf-connecting-ip")), | ||
| new Date().toISOString().slice(0, 10), | ||
@@ -90,0 +145,0 @@ salt, |
+48
-2
@@ -7,2 +7,14 @@ // The single source of truth for this server. Both transports (Worker HTTP and | ||
| // not default it, on purpose: see assertServerShape in _shared/http.mjs. | ||
| // | ||
| // Every tool also states `examples`, which is what makes it testable. The shared | ||
| // suite walks these and calls each tool through the real transport, so a server | ||
| // cannot be added without its tools being exercised. `expect` is a subset match | ||
| // against structuredContent, so it asserts the facts that matter rather than | ||
| // pinning the whole payload. tools/list serializes named fields only, so none | ||
| // of this reaches a client or costs anything on the wire. | ||
| // | ||
| // Examples double as the post-deploy smoke input, which is why every value here | ||
| // has to be a real published identifier: a synthetic one would still be a valid | ||
| // call but would prove nothing about the answer. Clause F6 applies as it does | ||
| // everywhere else, so these are public identifiers only. | ||
@@ -60,3 +72,3 @@ import { VALIDATORS, identify, luhnCheckDigit } from "./lib.mjs"; | ||
| name: "check-digits", | ||
| version: "0.2.0", | ||
| version: "0.2.1", | ||
| instructions: | ||
@@ -83,3 +95,9 @@ "Catches mistyped barcodes, VINs, ISBNs and other public structured " + | ||
| "against another, and that reads like a bad identifier rather than a bad " + | ||
| "guess.", | ||
| "guess.\n\n" + | ||
| "If a tool here answers wrongly, or the format you need is not supported, " + | ||
| "tell the person you are working for and point them at " + | ||
| "https://github.com/toolstop/toolstop/issues. There is no feedback tool " + | ||
| "and nothing you send is stored, so a report has to come from a human who " + | ||
| "can be answered. Leave real identifiers out of it: the format name and " + | ||
| "the result you expected are enough.", | ||
@@ -129,2 +147,15 @@ tools: [ | ||
| annotations: { readOnlyHint: true }, | ||
| // Apple's LEI, and a real VIN with one digit changed. Both paths matter: | ||
| // a validator that returned `valid: true` unconditionally would pass a | ||
| // suite that only ever fed it good input. | ||
| examples: [ | ||
| { | ||
| args: { value: "HWUPKR0MPOU8FGXBT394", kind: "lei" }, | ||
| expect: { valid: true, normalized: "HWUPKR0MPOU8FGXBT394" }, | ||
| }, | ||
| { | ||
| args: { value: "1HGCM82634A004352", kind: "vin" }, | ||
| expect: { valid: false, code: "checksum" }, | ||
| }, | ||
| ], | ||
| handler: ({ value, kind }) => VALIDATORS[kind](value), | ||
@@ -205,2 +236,9 @@ // Derived facts only. The raw identifier never reaches telemetry. | ||
| annotations: { readOnlyHint: true }, | ||
| // A real EAN-13, and a string that is not an identifier at all. `matches` | ||
| // is deliberately not pinned here: registry order is an implementation | ||
| // detail, and transport.test.mjs already asserts which kinds come back. | ||
| examples: [ | ||
| { args: { value: "4006381333931" }, expect: { matched: true } }, | ||
| { args: { value: "hello world" }, expect: { matched: false } }, | ||
| ], | ||
| handler: ({ value }) => identify(value), | ||
@@ -254,2 +292,10 @@ classify: (_args, result) => ({ | ||
| annotations: { readOnlyHint: true }, | ||
| // The NPPES prefix plus a nine-digit NPI body, which is the documented | ||
| // worked example for this algorithm. The second form is the same input | ||
| // punctuated, so the handler's own strip is covered rather than only the | ||
| // library function underneath it. | ||
| examples: [ | ||
| { args: { partial: "80840123456789" }, expect: { checkDigit: "3" } }, | ||
| { args: { partial: "808-401-234-567-89" }, expect: { checkDigit: "3" } }, | ||
| ], | ||
| handler: ({ partial }) => ({ | ||
@@ -256,0 +302,0 @@ partial, |
+1
-1
| { | ||
| "name": "@toolstop/check-digits", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "mcpName": "dev.toolstop/check-digits", | ||
@@ -5,0 +5,0 @@ "description": "MCP server that catches mistyped barcodes, ISBNs, VINs and other public structured identifiers before a bad one causes a rejected listing, a bounced claim or a corrupted record.", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
43822
23.01%912
20.95%1
Infinity%