diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd7bf0c8..c17599f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,16 @@ but the few rules below hold everywhere: argument is an options object, which therefore never read stdin on their own. All three types are derived from the public signatures, so `npm run check` fails when a new non-string option or argument, or a new params-only utility, is missing from them. +- `src/_mcp/` is the `brazilian-utils-mcp` server (a `bin` of `package.json`), laid out like + `src/_internals/`: one function per folder. It serves the Model Context Protocol over stdio, so + an agent calls the library instead of answering from memory. `handle-message` is the protocol as + a pure function (one decoded JSON-RPC message in, one response out), `serve-stdio` connects it + to a pair of streams, `call-tool` and `parse-tool-arguments` check a call against the tool's + JSON Schema and run it, and `brazilian-utils-mcp.ts` is the Node.js entry, built into + `dist/brazilian-utils-mcp.js` by its own pack config in `vite.config.ts` with the library left + external. No library entry point imports it, so it never reaches a consumer's bundle. The tools + are the table in `src/_mcp/constants.ts`, one per public function: a test compares it with + `src/index.ts`, so `npm run test` fails when a new utility has no tool. - There are no runtime dependencies (see [Zero runtime dependencies](#zero-runtime-dependencies)), so the trust boundary of the published package is this repository, its build toolchain and the npm registry; [MAINTAINERS.md](MAINTAINERS.md) lists who can change what, and @@ -147,6 +157,10 @@ example `formatSomething`): alphabetical ordering. Then add the function name to the `PUBLIC` list and the type(s) to the `publicTypes` map in `src/index.test.ts`, alphabetically. These two make up the package's public surface contract, and the test suite fails the build if either is out of sync. + Then add the tool that exposes it to agents to `TOOLS` in `src/_mcp/constants.ts`, also + alphabetically: the name of the function, a description written for a model, the JSON Schema of + its arguments and the properties to pass positionally. `src/_mcp/call-tool/call-tool.test.ts` + compares the table with `src/index.ts` and fails when a utility has no tool. 5. Document the utility in **both**: - `docs/utilities.md` (English) - `docs/pt-br/utilities.md` (Portuguese translation) diff --git a/README.md b/README.md index 75991737..81718f74 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - [Installation](#installation) - [Runtime support](#runtime-support) - [Usage](#usage) + - [MCP server](#mcp-server) - [Development](#development) - [Contributors](#contributors) - [License](#license) @@ -113,6 +114,23 @@ echo 01001000 | npx @brazilian-utils/brazilian-utils getAddressInfoByCep # rea The first argument is the name of a utility, the positional values are its arguments and `--key value`, `--flag` or `--json ''` become its options object. `list` prints every utility and `--help` the full usage. See [Command line](https://brazilian-utils.com.br/getting-started?id=command-line). +## MCP server + +The package also ships `brazilian-utils-mcp`, a [Model Context Protocol](https://modelcontextprotocol.io) server that hands every util to an agent as a tool, so it validates a CPF or reads a boleto by calling the library instead of answering from memory. Add it to Claude Desktop, Claude Code, Cursor or any other MCP client: + +```json +{ + "mcpServers": { + "brazilian-utils": { + "command": "npx", + "args": ["-y", "--package=@brazilian-utils/brazilian-utils", "brazilian-utils-mcp"] + } + } +} +``` + +It speaks stdio, implements revision 2026-07-28 of the specification and falls back to the `initialize` handshake of the older ones, and has no dependencies of its own. See [MCP server](https://brazilian-utils.com.br/getting-started?id=mcp-server). + ## Development This repository uses Vite+ as the local toolchain; it is installed as a dependency, so nothing has diff --git a/docs/getting-started.md b/docs/getting-started.md index 604cfcb6..620e6e41 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ --- title: "Getting Started" description: "Install Brazilian Utils, the zero-dependency utils library for Brazilian businesses, and learn how to import a util, which runtimes are supported and how the bundle size behaves." -keywords: ["Brazilian Utils", "install", "npm", "tree-shaking", "bundle size", "subpath imports", "Node.js", "Bun", "Deno", "browser", "AI assistants", "Context7"] +keywords: ["Brazilian Utils", "install", "npm", "tree-shaking", "bundle size", "subpath imports", "Node.js", "Bun", "Deno", "browser", "AI assistants", "Context7", "MCP"] --- Brazilian Utils is a library focused on solving problems that we face daily in the development of applications for the Brazilian business. @@ -120,6 +120,33 @@ To stop repeating it, add the rule to the agent's instructions file (`CLAUDE.md` Without Context7, point the assistant at [llms.txt](https://brazilian-utils.com.br/llms.txt), which lists every util with a one-line description and a link to its section, or at [llms-full.txt](https://brazilian-utils.com.br/llms-full.txt), the whole English documentation in one Markdown file. +## MCP server + +The package also ships `brazilian-utils-mcp`, a [Model Context Protocol](https://modelcontextprotocol.io) server that hands every util to an agent as a tool. The agent then validates a CPF, reads a boleto or looks an IBGE municipality up by calling the library, instead of answering from memory: + +```bash +npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp +``` + +It is a local server over stdio, so it goes in the client's configuration file the way any other one does. The same block works in Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows), in Claude Code (`.mcp.json` at the root of the project) and in Cursor (`.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project): + +```json +{ + "mcpServers": { + "brazilian-utils": { + "command": "npx", + "args": ["-y", "--package=@brazilian-utils/brazilian-utils", "brazilian-utils-mcp"] + } + } +} +``` + +In Claude Code, `claude mcp add brazilian-utils -- npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp` writes that file for you. Restart the client, and a prompt such as "is 111.444.777-35 a valid CPF, and which holidays does São Paulo have in 2026?" reaches the tools. + +There is one tool per util, named exactly as the function is exported (`isValidCpf`, `formatCnpj`, `getHolidays`), taking the same arguments and answering with its result as JSON. Documents are passed as strings, so leading zeros survive, and dates are written `YYYY-MM-DD`. An invalid value is an ordinary answer, not a failure: validators answer `false`, formatters and parsers `""`, lookups `null`. Everything is computed offline from the embedded datasets, `getAddressInfoByCep` and `getCepInfoByAddress` aside, the only two tools that reach the network. + +The server implements revision [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) of the specification, which negotiates the protocol version per request, and falls back to the `initialize` handshake of the older revisions, from 2025-11-25 down to 2024-11-05, for clients that speak one of those. It has no dependencies of its own: the stdio transport and the JSON-RPC surface ship with the package. Like the library, it is a separate file that no entry point imports, so it adds nothing to your bundle. + ## Bundle size The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 1.4 KB minified (0.8 KB gzipped) to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 950a7fbb..e1f9baba 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -11,6 +11,7 @@ - [Usage](#usage) - [Command line](#command-line) - [AI assistants](#ai-assistants) + - [MCP server](#mcp-server) - [Bundle size](#bundle-size) - [Utilities](#utilities) - [isValidCpf](#isvalidcpf) @@ -270,6 +271,33 @@ To stop repeating it, add the rule to the agent's instructions file (`CLAUDE.md` Without Context7, point the assistant at [llms.txt](https://brazilian-utils.com.br/llms.txt), which lists every util with a one-line description and a link to its section, or at [llms-full.txt](https://brazilian-utils.com.br/llms-full.txt), the whole English documentation in one Markdown file. +### MCP server + +The package also ships `brazilian-utils-mcp`, a [Model Context Protocol](https://modelcontextprotocol.io) server that hands every util to an agent as a tool. The agent then validates a CPF, reads a boleto or looks an IBGE municipality up by calling the library, instead of answering from memory: + +```bash +npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp +``` + +It is a local server over stdio, so it goes in the client's configuration file the way any other one does. The same block works in Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows), in Claude Code (`.mcp.json` at the root of the project) and in Cursor (`.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project): + +```json +{ + "mcpServers": { + "brazilian-utils": { + "command": "npx", + "args": ["-y", "--package=@brazilian-utils/brazilian-utils", "brazilian-utils-mcp"] + } + } +} +``` + +In Claude Code, `claude mcp add brazilian-utils -- npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp` writes that file for you. Restart the client, and a prompt such as "is 111.444.777-35 a valid CPF, and which holidays does São Paulo have in 2026?" reaches the tools. + +There is one tool per util, named exactly as the function is exported (`isValidCpf`, `formatCnpj`, `getHolidays`), taking the same arguments and answering with its result as JSON. Documents are passed as strings, so leading zeros survive, and dates are written `YYYY-MM-DD`. An invalid value is an ordinary answer, not a failure: validators answer `false`, formatters and parsers `""`, lookups `null`. Everything is computed offline from the embedded datasets, `getAddressInfoByCep` and `getCepInfoByAddress` aside, the only two tools that reach the network. + +The server implements revision [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) of the specification, which negotiates the protocol version per request, and falls back to the `initialize` handshake of the older revisions, from 2025-11-25 down to 2024-11-05, for clients that speak one of those. It has no dependencies of its own: the stdio transport and the JSON-RPC surface ship with the package. Like the library, it is a separate file that no entry point imports, so it adds nothing to your bundle. + ### Bundle size The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 1.4 KB minified (0.8 KB gzipped) to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index c68f0b3c..65b0966c 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -1,7 +1,7 @@ --- title: "Introdução" description: "Instale o Brazilian Utils, a biblioteca de utilitários sem dependências para o business brasileiro, e veja como importar um utilitário, quais runtimes são suportados e como o tamanho do bundle se comporta." -keywords: ["Brazilian Utils", "instalação", "npm", "tree-shaking", "tamanho do bundle", "subpath", "Node.js", "Bun", "Deno", "navegador", "assistentes de IA", "Context7"] +keywords: ["Brazilian Utils", "instalação", "npm", "tree-shaking", "tamanho do bundle", "subpath", "Node.js", "Bun", "Deno", "navegador", "assistentes de IA", "Context7", "MCP"] --- Brazilian Utils é uma biblioteca com foco na resolução de problemas que enfrentamos diariamente no desenvolvimento de aplicações para o business brasileiro. @@ -120,6 +120,33 @@ Para não repetir isso a cada prompt, coloque a regra no arquivo de instruções Sem o Context7, aponte o assistente para o [llms.txt](https://brazilian-utils.com.br/llms.txt), que lista todos os utilitários com uma descrição de uma linha e o link para a seção de cada um, ou para o [llms-full.txt](https://brazilian-utils.com.br/llms-full.txt), a documentação completa em inglês em um único arquivo Markdown. +## Servidor MCP + +O pacote também traz o `brazilian-utils-mcp`, um servidor [Model Context Protocol](https://modelcontextprotocol.io) que entrega cada utilitário ao agente como uma ferramenta. Assim o agente valida um CPF, lê um boleto ou busca um município do IBGE chamando a biblioteca, em vez de responder de memória: + +```bash +npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp +``` + +É um servidor local, que fala por stdio, então entra no arquivo de configuração do cliente como qualquer outro. O mesmo bloco funciona no Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` no macOS, `%APPDATA%\Claude\claude_desktop_config.json` no Windows), no Claude Code (`.mcp.json` na raiz do projeto) e no Cursor (`.cursor/mcp.json` no projeto, ou `~/.cursor/mcp.json` para todos eles): + +```json +{ + "mcpServers": { + "brazilian-utils": { + "command": "npx", + "args": ["-y", "--package=@brazilian-utils/brazilian-utils", "brazilian-utils-mcp"] + } + } +} +``` + +No Claude Code, `claude mcp add brazilian-utils -- npx -y --package=@brazilian-utils/brazilian-utils brazilian-utils-mcp` escreve esse arquivo para você. Reinicie o cliente e um prompt como "111.444.777-35 é um CPF válido? E quais são os feriados de São Paulo em 2026?" chega às ferramentas. + +Há uma ferramenta por utilitário, com o mesmo nome da função exportada (`isValidCpf`, `formatCnpj`, `getHolidays`), que recebe os mesmos argumentos e responde com o resultado em JSON. Documentos são passados como texto, para que os zeros à esquerda sobrevivam, e datas são escritas como `YYYY-MM-DD`. Um valor inválido é uma resposta comum, não uma falha: validadores respondem `false`, formatadores e parsers `""`, buscas `null`. Tudo é calculado offline, a partir dos datasets embutidos, com exceção de `getAddressInfoByCep` e `getCepInfoByAddress`, as duas únicas ferramentas que acessam a rede. + +O servidor implementa a revisão [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) da especificação, que negocia a versão do protocolo a cada requisição, e volta para o handshake `initialize` das revisões anteriores, da 2025-11-25 até a 2024-11-05, para os clientes que falam uma delas. Ele não tem dependências próprias: o transporte stdio e a superfície JSON-RPC vêm no pacote. Como a biblioteca, é um arquivo separado que nenhum entry point importa, então não adiciona nada ao seu bundle. + ## Tamanho do bundle O pacote é tree-shakeable: importar um utilitário da raiz traz apenas o código daquele utilitário, não o resto da biblioteca. `isValidCpf`, por exemplo, adiciona cerca de 1,4 KB minificado (0,8 KB com gzip) ao seu bundle. Um bundler com suporte a tree-shaking (webpack, Rollup, esbuild, Vite, etc.) descarta todos os outros utilitários. diff --git a/package.json b/package.json index a398d262..b3ea0a2f 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "url": "git+https://github.com/brazilian-utils/javascript.git" }, "bin": { - "brazilian-utils": "./dist/cli.js" + "brazilian-utils": "./dist/cli.js", + "brazilian-utils-mcp": "./dist/brazilian-utils-mcp.js" }, "files": [ "./CHANGELOG.md", @@ -88,6 +89,7 @@ } }, "./cli": null, + "./brazilian-utils-mcp": null, "./*": { "import": { "types": "./dist/*.d.ts", diff --git a/src/_mcp/brazilian-utils-mcp.test.ts b/src/_mcp/brazilian-utils-mcp.test.ts new file mode 100644 index 00000000..e98f8edb --- /dev/null +++ b/src/_mcp/brazilian-utils-mcp.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "../_internals/test/runtime"; + +type Exchange = { + code: number | null; + stdout: string; + stderr: string; +}; + +/** + * The bin is a process, so this suite only runs where one can be spawned: Node.js. Bun, Deno and + * the browsers still run every other suite of the server, which covers all of its logic in-process. + */ +const runtime: { process?: { versions?: { node?: string } } } = globalThis; + +const canSpawn = + typeof runtime.process?.versions?.node === "string" && + !("Bun" in globalThis) && + !("Deno" in globalThis); + +const MODERN_META = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, +}; + +/** + * Bundles the bin into a throwaway package laid out like the published one (`package.json` next + * to `dist/`), spawns it, writes `lines` to its stdin, closes it and waits for the exit. + * @param {string[]} lines What the client writes, one message per line. + * @returns {Promise} The exit code and everything the process wrote. + */ +const converse = async (lines: string[]): Promise => { + const { spawn } = await import("node:child_process"); + const { mkdtemp, rm, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const path = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { build } = await import("esbuild"); + + const root = await mkdtemp(path.join(tmpdir(), "brazilian-utils-mcp-")); + const bin = path.join(root, "dist", "brazilian-utils-mcp.js"); + + try { + await writeFile(path.join(root, "package.json"), '{"type":"module","version":"0.0.0-test"}'); + await build({ + entryPoints: [fileURLToPath(new URL("brazilian-utils-mcp.ts", import.meta.url).href)], + outfile: bin, + bundle: true, + format: "esm", + platform: "node", + logLevel: "silent", + }); + + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [bin], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ code, stdout, stderr }); + }); + child.stdin.end(lines.map((line) => `${line}\n`).join("")); + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}; + +const suite = canSpawn ? describe : describe.skip; + +suite("brazilian-utils-mcp bin", () => { + test("should speak both protocol eras over stdio, keep stdout clean and exit when stdin closes", async () => { + const { code, stdout, stderr } = await converse([ + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "e2e", version: "1.0.0" }, + }, + }), + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ping" }), + JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" }), + JSON.stringify({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "isValidCpf", arguments: { value: "111.444.777-35" } }, + }), + JSON.stringify({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "formatCnpj", arguments: { value: 24_522_200_000_174 } }, + }), + JSON.stringify({ + jsonrpc: "2.0", + id: 6, + method: "server/discover", + params: { _meta: MODERN_META }, + }), + JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { + name: "getHolidays", + arguments: { year: 2024, stateCode: "SP" }, + _meta: MODERN_META, + }, + }), + "this is not json", + JSON.stringify({ jsonrpc: "2.0", id: 8, method: "resources/list" }), + ]); + + const responses: any[] = stdout + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)); + const byId = (id: number): any => responses.find((response) => response.id === id); + + expect(code).toBe(0); + expect(stderr).toBe( + "brazilian-utils-mcp 0.0.0-test: serving 136 tools on stdio\nbrazilian-utils-mcp: discarded a line that is not JSON\n", + ); + expect(stdout.endsWith("\n")).toBe(true); + expect(responses).toHaveLength(9); + + expect(byId(1).result.protocolVersion).toBe("2025-11-25"); + expect(byId(1).result.capabilities).toStrictEqual({ tools: {} }); + expect(byId(1).result.serverInfo).toStrictEqual({ + name: "brazilian-utils", + version: "0.0.0-test", + }); + expect(byId(2)).toStrictEqual({ jsonrpc: "2.0", id: 2, result: {} }); + expect(byId(3).result.tools).toHaveLength(136); + expect(byId(3).result.tools[0].name).toBe("addBusinessDays"); + expect(byId(4).result).toStrictEqual({ + content: [{ type: "text", text: "true" }], + isError: false, + }); + expect(byId(5).result).toStrictEqual({ + content: [{ type: "text", text: "arguments.value must be of type string" }], + isError: true, + }); + expect(byId(6).result.resultType).toBe("complete"); + expect(byId(6).result.supportedVersions).toStrictEqual([ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", + ]); + expect(byId(7).result.resultType).toBe("complete"); + expect(JSON.parse(byId(7).result.content[0].text)).toContainEqual({ + name: "Revolução Constitucionalista", + date: "2024-07-09", + type: "state", + }); + expect(byId(8).error).toStrictEqual({ + code: -32_601, + message: "Method not found: resources/list", + }); + expect(responses.filter((response) => response.id === undefined)).toStrictEqual([ + { jsonrpc: "2.0", error: { code: -32_700, message: "Parse error" } }, + ]); + }, 60_000); +}); diff --git a/src/_mcp/brazilian-utils-mcp.ts b/src/_mcp/brazilian-utils-mcp.ts new file mode 100644 index 00000000..54deda17 --- /dev/null +++ b/src/_mcp/brazilian-utils-mcp.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import process from "node:process"; + +import * as library from "../index"; +import { SERVER_NAME, TOOLS } from "./constants"; +import { serveStdio } from "./serve-stdio/serve-stdio"; + +const manifest: unknown = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); + +serveStdio({ + input: process.stdin, + output: process.stdout, + log: process.stderr, + context: { + serverInfo: { + name: SERVER_NAME, + version: + typeof manifest === "object" && manifest !== null && "version" in manifest + ? String(manifest.version) + : "unknown", + }, + tools: TOOLS, + library, + }, +}); diff --git a/src/_mcp/call-tool/call-tool.test.ts b/src/_mcp/call-tool/call-tool.test.ts new file mode 100644 index 00000000..8349913b --- /dev/null +++ b/src/_mcp/call-tool/call-tool.test.ts @@ -0,0 +1,677 @@ +import * as fc from "fast-check"; + +import { anyValue } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import * as library from "../../index"; +import { type McpTool, TOOLS } from "../constants"; +import { callTool, type CallToolParams, type CallToolResult } from "./call-tool"; + +const BOLETO = "00190000090114971860168524522114675860000102656"; +const NFE_KEY = "35170458716523000119550010000000121000123458"; +const CERTIDAO = "104539 01 55 2013 1 00012 021 0000123 21"; +const IBAN = "BR1500000000000010932840814P2"; +const PIX_PAYLOAD = + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D"; + +/** Every deterministic tool with the arguments of a documented example and the exact JSON answer. */ +const EXACT_CASES: [name: string, args: Record, text: string][] = [ + ["addBusinessDays", { date: "2024-12-31", amount: 1 }, '"2025-01-02"'], + [ + "addBusinessDays", + { date: "2024-07-08", amount: 1, options: { stateCode: "SP" } }, + '"2024-07-10"', + ], + ["capitalize", { value: "JOSÉ DA SILVA" }, '"José da Silva"'], + ["capitalize", { value: "empresa ltda", options: { upperCaseWords: [] } }, '"Empresa Ltda"'], + [ + "convertCurrencyToWords", + { value: 1523.45 }, + '"mil quinhentos e vinte e três reais e quarenta e cinco centavos"', + ], + ["convertDateToWords", { value: "2024-01-02" }, '"dois de janeiro de dois mil e vinte e quatro"'], + [ + "convertDateToWords", + { value: "02/03/2024", options: { style: "month" } }, + '"2 de março de 2024"', + ], + ["convertLicensePlateToMercosul", { value: "ABC1234" }, '"ABC1C34"'], + ["convertNumberToWords", { value: 1001 }, '"mil e um"'], + ["convertNumberToWords", { value: 2, options: { gender: "feminine" } }, '"duas"'], + ["differenceInBusinessDays", { laterDate: "2024-01-03", earlierDate: "2024-01-02" }, "1"], + ["formatBoleto", { value: BOLETO }, '"00190.00009 01149.718601 68524.522114 6 75860000102656"'], + ["formatCaepf", { value: "29311861000184" }, '"293.118.610/001-84"'], + ["formatCei", { value: "277297118187" }, '"27.729.71181/87"'], + ["formatCep", { value: "9250000", options: { pad: true } }, '"09250-000"'], + [ + "formatCertidao", + { value: "10453901552013100012021000012321" }, + '"104539 01 55 2013 1 00012 021 0000123 21"', + ], + ["formatCnae", { value: "6201501" }, '"6201-5/01"'], + ["formatCnh", { value: "2650306461", options: { pad: true } }, '"026503064-61"'], + ["formatCno", { value: "111130137368" }, '"11.113.01373/68"'], + ["formatCnpj", { value: "12OUT345000199", options: { version: 2 } }, '"12.OUT.345/0001-99"'], + ["formatCnpj", { value: "12345678000195", options: { obfuscate: true } }, '"**.345.678/0001-**"'], + ["formatCns", { value: "123456789010000" }, '"123 4567 8901 0000"'], + ["formatCpf", { value: "746506880", options: { pad: true } }, '"007.465.068-80"'], + ["formatCpf", { value: "12345678909", options: { obfuscate: true } }, '"***.456.789-**"'], + ["formatCurrency", { value: 10_756.11 }, '"10.756,11"'], + ["formatCurrency", { value: 10, options: { symbol: true, precision: 3 } }, '"R$ 10,000"'], + ["formatIban", { value: IBAN }, '"BR15 0000 0000 0000 1093 2840 814P 2"'], + ["formatLegalNature", { value: "2062" }, '"206-2"'], + ["formatLicensePlate", { value: "abc1234" }, '"ABC-1234"'], + ["formatNcm", { value: "84713012" }, '"8471.30.12"'], + ["formatNfeKey", { value: "12345" }, '"1234 5"'], + ["formatPassport", { value: "AB-123.456" }, '"AB123456"'], + ["formatPhone", { value: "11900000000", options: { mask: "nanp" } }, '"(11) 90000-0000"'], + ["formatPis", { value: "12345678901" }, '"123.45678.90-1"'], + ["formatProcessoJuridico", { value: "00020802520125150049" }, '"0002080-25.2012.5.15.0049"'], + ["formatVoterId", { value: "1234567880191" }, '"1234 5678 8 01 91"'], + [ + "generatePixPayload", + { + key: "123.456.789-09", + merchantName: "Fulano de Tal", + merchantCity: "Brasília", + amount: 123.45, + }, + '"00020126330014br.gov.bcb.pix0111123456789095204000053039865406123.455802BR5913Fulano de Tal6008Brasilia62070503***630479EE"', + ], + ["generatePixPayload", { merchantName: "Fulano", merchantCity: "Brasília" }, "null"], + ["generateProcessoJuridico", { court: 10 }, "null"], + [ + "getAreaCodeInfo", + { value: "61" }, + '{"areaCode":61,"stateCode":"DF","stateName":"Distrito Federal","regionCode":"CO","regionName":"Centro-Oeste","stateCodes":["DF","GO"]}', + ], + ["getAreaCodesByState", { stateCode: "sp" }, "[11,12,13,14,15,16,17,18,19]"], + [ + "getBankByCode", + { value: "001" }, + '{"code":"001","ispb":"00000000","name":"Banco do Brasil S.A."}', + ], + [ + "getBankByIspb", + { value: "60701190" }, + '{"code":"341","ispb":"60701190","name":"ITAÚ UNIBANCO S.A."}', + ], + [ + "getBoletoInfo", + { value: BOLETO, options: { referenceDate: "2018-07-01" } }, + '{"amount":102656,"expirationDate":"2018-07-15","bankCode":"001"}', + ], + ["getBoletoInfo", { value: "invalid" }, "null"], + [ + "getCbo", + { value: "2124-05" }, + '{"code":"212405","description":"Analista de desenvolvimento de sistemas"}', + ], + [ + "getCertidaoInfo", + { value: CERTIDAO }, + '{"registryCns":"104539","acervo":"01","service":"55","year":2013,"type":"birth","typeCode":1,"book":"00012","page":"021","term":"0000123","checkDigits":"21"}', + ], + [ + "getCfop", + { value: "1101" }, + '{"code":"1101","description":"Compra para industrialização ou produção rural"}', + ], + ["getCnae", { value: "0111301" }, '{"code":"0111301","description":"CULTIVO DE ARROZ"}'], + ["getFormatLicensePlate", { value: "ABC1D23" }, '"LLLNLNN"'], + [ + "getIbanInfo", + { value: IBAN }, + '{"countryCode":"BR","checkDigits":"15","bankIspb":"00000000","branch":"00001","account":"0932840814","accountType":"P","owner":"2"}', + ], + [ + "getLegalNature", + { value: "2208" }, + '{"code":"2208","description":"Entidade Binacional Itaipu","category":{"code":"2","description":"Entidades Empresariais"},"legacy":true,"currentCode":"2275"}', + ], + [ + "getMunicipalityByCode", + { value: "3550308" }, + '{"code":"3550308","name":"São Paulo","stateCode":"SP"}', + ], + [ + "getNfeKeyInfo", + { value: NFE_KEY }, + '{"stateCode":"SP","year":2017,"month":4,"taxId":"58716523000119","model":"55","series":1,"number":12,"emissionType":1,"code":"00012345","checkDigit":8}', + ], + [ + "getPixKeyInfo", + { value: "Fulano@Example.COM " }, + '{"type":"email","value":"fulano@example.com"}', + ], + [ + "getPixPayloadInfo", + { value: PIX_PAYLOAD }, + '{"merchantName":"Fulano de Tal","merchantCity":"BRASILIA","pointOfInitiation":"static","key":"123e4567-e12b-12d1-a456-426655440000"}', + ], + [ + "getStateByIbgeCode", + { value: "35" }, + '{"code":"SP","name":"São Paulo","regionCode":"SE","regionName":"Sudeste","ibgeCode":35}', + ], + ["getStateCodeByName", { name: "sao paulo" }, '"SP"'], + ["getStateNameByCode", { stateCode: "sp" }, '"São Paulo"'], + ["getTimezoneByState", { stateCode: "am" }, '"America/Manaus"'], + ["isBusinessDay", { date: "2024-01-02" }, "true"], + ["isBusinessDay", { date: "2024-02-13" }, "false"], + ["isBusinessDay", { date: "2024-02-13", options: { includeOptional: false } }, "true"], + ["isHoliday", { targetDate: "2024-01-01" }, "true"], + ["isHoliday", { targetDate: "2024-07-09" }, "false"], + ["isHoliday", { targetDate: "2024-07-09", stateCode: "SP" }, "true"], + ["isValidBankAccount", { bankCode: "341", agency: "2545", account: "02366", digit: "1" }, "true"], + [ + "isValidBankAccount", + { bankCode: "341", agency: "2545", account: "02366", digit: "2" }, + "false", + ], + ["isValidBoleto", { value: BOLETO }, "true"], + ["isValidCaepf", { value: "293.118.610/001-84" }, "true"], + ["isValidCbo", { value: "2124-05" }, "true"], + ["isValidCei", { value: "11.583.00249/85" }, "true"], + ["isValidCep", { value: "01310100" }, "true"], + ["isValidCertidao", { value: CERTIDAO }, "true"], + ["isValidCertidao", { value: CERTIDAO, options: { accept: ["death"] } }, "false"], + ["isValidCfop", { value: "5102" }, "true"], + ["isValidCnae", { value: "6201-5/01" }, "true"], + ["isValidCnh", { value: "00000000119" }, "true"], + ["isValidCno", { value: "11.084.01680/62" }, "true"], + ["isValidCnpj", { value: "q0slfmbd7vx439" }, "false"], + ["isValidCnpj", { value: "q0slfmbd7vx439", options: { version: 2 } }, "true"], + ["isValidCns", { value: "700000000000005" }, "true"], + ["isValidCpf", { value: "111 444 777 35" }, "true"], + ["isValidCpf", { value: "155151475" }, "false"], + ["isValidCreditCard", { value: "4111111111111111" }, "true"], + ["isValidCsosn", { value: "101" }, "true"], + ["isValidCst", { value: "06", options: { tax: "pis" } }, "true"], + ["isValidCst", { value: "06", options: { tax: "icms" } }, "false"], + ["isValidEmail", { value: "john.doe@hotmail.com" }, "true"], + ["isValidIban", { value: IBAN }, "true"], + ["isValidIe", { value: "109161793", stateCode: "go" }, "true"], + ["isValidLandlinePhone", { value: "1130000000" }, "true"], + ["isValidLegalNature", { value: "2062" }, "true"], + ["isValidLicensePlate", { value: "ABC-1234" }, "true"], + ["isValidMobilePhone", { value: "11612345678" }, "true"], + ["isValidMobilePhone", { value: "11612345678", options: { version: 2 } }, "false"], + ["isValidNcm", { value: "8471.30.12" }, "true"], + ["isValidNfeKey", { value: NFE_KEY }, "true"], + ["isValidPassport", { value: "AB123456" }, "true"], + ["isValidPhone", { value: "0800 123 4567" }, "false"], + ["isValidPhone", { value: "0800 123 4567", options: { accept: ["service"] } }, "true"], + ["isValidPis", { value: "12056412847" }, "true"], + ["isValidPixKey", { value: "fulano@example.com" }, "true"], + ["isValidPixKey", { value: "fulano@example.com", options: { accept: ["cpf"] } }, "false"], + ["isValidPixPayload", { value: PIX_PAYLOAD }, "true"], + ["isValidProcessoJuridico", { value: "0002080-25.2012.5.15.0049" }, "true"], + ["isValidRegistroProfissional", { value: "123456/SP", council: "OAB" }, "true"], + ["isValidRegistroProfissional", { value: "123456-RJ", council: "OAB", stateCode: "SP" }, "false"], + ["isValidRenavam", { value: "00639884962" }, "true"], + ["isValidServicePhone", { value: "4004-1234" }, "true"], + ["isValidVin", { value: "1HGCM82633A004352" }, "true"], + ["isValidVoterId", { value: "102385010671" }, "true"], + [ + "parseBoleto", + { value: "00190.00009 01149.718601 68524.522114 6 75860000102656" }, + `"${BOLETO}"`, + ], + ["parseCaepf", { value: "293.118.610/001-84" }, '"29311861000184"'], + ["parseCbo", { value: "2124-05" }, '"212405"'], + ["parseCei", { value: "27.729.71181/87" }, '"277297118187"'], + ["parseCep", { value: "92500-000" }, '"92500000"'], + ["parseCertidao", { value: CERTIDAO }, '"10453901552013100012021000012321"'], + ["parseCfop", { value: "5.102" }, '"5102"'], + ["parseCnae", { value: "6201-5/01" }, '"6201501"'], + ["parseCnh", { value: "026503064-61" }, '"02650306461"'], + ["parseCno", { value: "11.113.01373/68" }, '"111130137368"'], + ["parseCnpj", { value: "12.OUT.345/0001-99" }, '"12345000199"'], + ["parseCnpj", { value: "12.OUT.345/0001-99", options: { version: 2 } }, '"12OUT345000199"'], + ["parseCns", { value: "123 4567 8901 0000" }, '"123456789010000"'], + ["parseCpf", { value: "746.506.880-00" }, '"74650688000"'], + ["parseCurrency", { value: "R$ 1.234,56" }, "1234.56"], + ["parseCurrency", { value: "1,2345", options: { precision: 4 } }, "1.2345"], + ["parseIban", { value: "br15-0000.0000/0000 1093 2840 814p-2" }, `"${IBAN}"`], + ["parseLegalNature", { value: "206-2" }, '"2062"'], + ["parseLicensePlate", { value: "abc-1234" }, '"ABC1234"'], + ["parseNcm", { value: "8471.30.12" }, '"84713012"'], + ["parseNfeKey", { value: `NFe${NFE_KEY}` }, `"${NFE_KEY}"`], + ["parsePassport", { value: " AB 123 456 " }, '"AB123456"'], + ["parsePhone", { value: "+55 (11) 98765-4321" }, '"11987654321"'], + ["parsePis", { value: "123.45678.90-1" }, '"12345678901"'], + ["parseProcessoJuridico", { value: "0002080-25.2012.5.15.0049" }, '"00020802520125150049"'], + ["parseVoterId", { value: "1234 5678 8 01 91" }, '"1234567880191"'], + ["removeAccents", { value: "São Paulo" }, '"Sao Paulo"'], + ["subBusinessDays", { date: "2024-01-08", amount: 1 }, '"2024-01-05"'], +]; + +/** The random generators: the arguments and the shape every answer has. */ +const GENERATOR_CASES: [name: string, args: Record, shape: RegExp][] = [ + ["generateBoleto", {}, /^"\d{47}"$/], + ["generateBoleto", { type: "arrecadacao" }, /^"8\d{47}"$/], + ["generateCep", {}, /^"\d{8}"$/], + ["generateCnh", {}, /^"\d{11}"$/], + ["generateCnpj", {}, /^"\d{14}"$/], + ["generateCnpj", { version: 2, branch: 1 }, /^"[0-9A-Z]{8}0001\d{2}"$/], + ["generateCpf", {}, /^"\d{11}"$/], + ["generateCpf", { stateCode: "SP" }, /^"\d{8}8\d{2}"$/], + ["generateLegalNature", {}, /^"\d{4}"$/], + ["generateLicensePlate", { format: "LLLNNNN" }, /^"[A-Z]{3}\d{4}"$/], + ["generateLicensePlate", { format: "LLLNLNN" }, /^"[A-Z]{3}\d[A-Z]\d{2}"$/], + ["generatePassport", {}, /^"[A-Z]{2}\d{6}"$/], + ["generatePhone", { type: "mobile" }, /^"\d{2}9\d{8}"$/], + ["generatePis", {}, /^"\d{11}"$/], + ["generateProcessoJuridico", { year: 2999, court: 5 }, /^"\d{9}29995\d{6}"$/], + ["generateRenavam", {}, /^"\d{11}"$/], + ["generateVoterId", {}, /^"\d{8}28\d{2}"$/], + ["generateVoterId", { stateCode: "SP" }, /^"\d{8}01\d{2}"$/], +]; + +/** The list tools: the arguments, the number of entries and the first entry of the answer. */ +const LIST_CASES: [name: string, args: Record, length: number, first: unknown][] = + [ + ["getHolidays", { year: 2024 }, 13, { name: "Ano novo", date: "2024-01-01", type: "national" }], + [ + "getLegalNaturesByCategory", + { category: "4" }, + 6, + { + code: "4014", + description: "Empresa Individual Imobiliária", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, + ], + [ + "getLegalNaturesByCategory", + { category: "2", options: { includeLegacy: true } }, + 33, + { + code: "2011", + description: "Empresa Pública", + category: { code: "2", description: "Entidades Empresariais" }, + legacy: false, + }, + ], + [ + "getMunicipalities", + { stateCode: "SP" }, + 645, + { code: "3500105", name: "Adamantina", stateCode: "SP" }, + ], + [ + "getStates", + {}, + 27, + { code: "AC", name: "Acre", regionCode: "N", regionName: "Norte", ibgeCode: 12 }, + ], + ]; + +const NETWORK_TOOLS = ["getAddressInfoByCep", "getCepInfoByAddress"]; + +const findTool = (name: string): McpTool => { + const tool = TOOLS.find((candidate) => candidate.name === name); + if (tool === undefined) throw new Error(`No tool named ${name}`); + + return tool; +}; + +const call = (name: string, args: Record): Promise => + callTool({ tool: findTool(name), args, library }); + +const FAKE_TOOL: McpTool = { + name: "fake", + description: "A tool for the tests.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { value: { type: "string" }, options: { type: "object", properties: {} } }, + required: ["value"], + additionalProperties: false, + }, +}; + +const visitSchema = (schema: McpTool["inputSchema"]): void => { + if (schema.type === "object") { + expect(schema.additionalProperties).toBe(false); + expect(schema.properties).toBeDefined(); + } + + const properties = schema.properties ?? {}; + for (const key of schema.required ?? []) expect(Object.keys(properties)).toContain(key); + for (const property of Object.values(properties)) visitSchema(property); + if (schema.items !== undefined) visitSchema(schema.items); +}; + +const returnUndefined = (): undefined => undefined; + +const returnAsynchronously = (value: string): Promise => Promise.resolve([value]); + +const throwRangeError = (): never => { + throw new RangeError("out of range"); +}; + +const rejectOffline = (): Promise => Promise.reject(new Error("offline")); + +const catchFailure = async (fakes: Record): Promise => { + try { + return await callTool({ tool: FAKE_TOOL, args: {}, library: fakes }); + } catch (error) { + return error; + } +}; + +const OFFLINE_TOOLS = TOOLS.filter((tool) => tool.network !== true); + +const anyOfflineTool = fc.constantFrom(...OFFLINE_TOOLS); + +const anyValueTool = fc.constantFrom( + ...OFFLINE_TOOLS.filter((tool) => tool.inputSchema.required?.join(",") === "value"), +); + +const anyArguments = fc.dictionary( + fc.constantFrom("value", "options", "date", "__proto__", "x"), + anyValue, +); + +describe("callTool", () => { + describe("the tool table against the library", () => { + for (const [name, args, text] of EXACT_CASES) { + test(`should answer ${name}(${JSON.stringify(args)}) with ${text}`, async () => { + expect(await call(name, args)).toStrictEqual({ + content: [{ type: "text", text }], + isError: false, + }); + }); + } + + for (const [name, args, shape] of GENERATOR_CASES) { + test(`should answer ${name}(${JSON.stringify(args)}) with a value shaped as ${String(shape)}`, async () => { + const result = await call(name, args); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toMatch(shape); + }); + } + + for (const [name, args, length, first] of LIST_CASES) { + test(`should answer ${name}(${JSON.stringify(args)}) with ${length} entries`, async () => { + const result = await call(name, args); + const list: unknown[] = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(false); + expect(list).toHaveLength(length); + expect(list[0]).toStrictEqual(first); + }); + } + + test("should answer getBanks with the bank list, Banco do Brasil first", async () => { + const result = await call("getBanks", {}); + const banks: unknown[] = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(false); + expect(banks.length).toBeGreaterThan(100); + expect(banks[0]).toStrictEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("should answer getLegalNatures with the map of codes in force, or with the retired ones too", async () => { + const inForceResult = await call("getLegalNatures", {}); + const allResult = await call("getLegalNatures", { includeLegacy: true }); + const inForce = JSON.parse(inForceResult.content[0].text); + const all = JSON.parse(allResult.content[0].text); + + expect(Object.keys(inForce)).toHaveLength(92); + expect(inForce["2062"]).toBe("Sociedade Empresária Limitada"); + expect(inForce["2208"]).toBeUndefined(); + expect(Object.keys(all)).toHaveLength(100); + expect(all["2208"]).toBe("Entidade Binacional Itaipu"); + }); + + test("should report the rejection of a network tool as a tool error, without any request", async () => { + expect(await call("getAddressInfoByCep", { value: "123" })).toStrictEqual({ + content: [{ type: "text", text: "GetAddressInfoByCepValidationError: CEP inválido" }], + isError: true, + }); + expect( + await call("getCepInfoByAddress", { federalUnit: "XX", city: "Ouro Preto", street: "Rua" }), + ).toStrictEqual({ + content: [{ type: "text", text: "GetCepInfoByAddressValidationError: Invalid UF: XX" }], + isError: true, + }); + }); + + test("should have a case for every tool", () => { + const covered = new Set([ + ...EXACT_CASES.map(([name]) => name), + ...GENERATOR_CASES.map(([name]) => name), + ...LIST_CASES.map(([name]) => name), + ...NETWORK_TOOLS, + "getBanks", + "getLegalNatures", + ]); + + expect(TOOLS.map((tool) => tool.name).filter((name) => !covered.has(name))).toStrictEqual([]); + }); + }); + + describe("the tool table", () => { + const DEPRECATED = new Set([ + "formatCEP", + "formatCNPJ", + "formatCPF", + "generateCNPJ", + "generateCPF", + "getCities", + "getMunicipality", + "isValidCEP", + "isValidCNPJ", + "isValidCPF", + "isValidIE", + "isValidPIS", + ]); + + test("should list one tool per public function, the deprecated ones aside", () => { + const functions = Object.entries(library) + .filter(([name, value]) => typeof value === "function" && !/^[A-Z]/.test(name)) + .map(([name]) => name) + .filter((name) => !DEPRECATED.has(name)) + .toSorted(); + + expect(TOOLS.map((tool) => tool.name)).toStrictEqual(functions); + expect(TOOLS).toHaveLength(136); + }); + + test("should mark the two CEP lookups, and only them, as network tools", () => { + expect(TOOLS.filter((tool) => tool.network).map((tool) => tool.name)).toStrictEqual( + NETWORK_TOOLS, + ); + }); + + test("should name tools within the characters and the length the specification allows", () => { + for (const tool of TOOLS) { + expect(tool.name).toMatch(/^[A-Za-z0-9_.-]{1,128}$/); + expect(tool.description.length).toBeGreaterThan(20); + } + }); + + test("should close every object schema and require only properties it lists", () => { + for (const tool of TOOLS) { + expect(tool.inputSchema.type).toBe("object"); + visitSchema(tool.inputSchema); + } + }); + + test("should pass only properties the input schema lists", () => { + for (const tool of TOOLS) { + const listed = Object.keys(tool.inputSchema.properties ?? {}); + const passed = tool.parameters === "object" ? listed : tool.parameters; + + expect([...passed].toSorted()).toStrictEqual(listed.toSorted()); + } + }); + }); + + describe("arguments", () => { + test("should pass the listed properties positionally, a missing one as undefined", async () => { + const calls: unknown[][] = []; + const fake = (...args: unknown[]): string => { + calls.push(args); + return "ok"; + }; + + await callTool({ tool: FAKE_TOOL, args: { value: "a", options: {} }, library: { fake } }); + await callTool({ tool: FAKE_TOOL, args: { value: "b" }, library: { fake } }); + + expect(calls).toStrictEqual([ + ["a", {}], + ["b", undefined], + ]); + }); + + test("should pass the whole arguments object when the tool takes one", async () => { + const calls: unknown[][] = []; + const fake = (...args: unknown[]): null => { + calls.push(args); + return null; + }; + const tool: McpTool = { ...FAKE_TOOL, parameters: "object" }; + + await callTool({ tool, args: { value: "a" }, library: { fake } }); + + expect(calls).toStrictEqual([[{ value: "a" }]]); + }); + + test("should answer arguments that break the schema with a tool error and not call the function", async () => { + let called = false; + const fake = (): void => { + called = true; + }; + + expect(await callTool({ tool: FAKE_TOOL, args: {}, library: { fake } })).toStrictEqual({ + content: [{ type: "text", text: "arguments.value is required" }], + isError: true, + }); + expect( + await callTool({ tool: FAKE_TOOL, args: { value: 1 }, library: { fake } }), + ).toStrictEqual({ + content: [{ type: "text", text: "arguments.value must be of type string" }], + isError: true, + }); + expect(called).toBe(false); + expect(await call("isHoliday", { targetDate: "01/01/2024" })).toStrictEqual({ + content: [ + { + type: "text", + text: "arguments.targetDate must be a calendar date written as YYYY-MM-DD", + }, + ], + isError: true, + }); + expect(await call("generateCpf", { stateCode: "XX" })).toMatchObject({ isError: true }); + }); + }); + + describe("results", () => { + test("should write undefined as null and a Date as a calendar date", async () => { + expect( + await callTool({ + tool: FAKE_TOOL, + args: { value: "" }, + library: { fake: returnUndefined }, + }), + ).toStrictEqual({ content: [{ type: "text", text: "null" }], isError: false }); + expect( + await callTool({ + tool: FAKE_TOOL, + args: { value: "" }, + library: { fake: () => ({ on: new Date(2024, 0, 1) }) }, + }), + ).toStrictEqual({ content: [{ type: "text", text: '{"on":"2024-01-01"}' }], isError: false }); + }); + + test("should await an asynchronous function", async () => { + const fakes = { fake: returnAsynchronously }; + + expect( + await callTool({ tool: FAKE_TOOL, args: { value: "a" }, library: fakes }), + ).toStrictEqual({ content: [{ type: "text", text: '["a"]' }], isError: false }); + }); + + test("should answer an error the function throws or rejects with as a tool error", async () => { + const args = { value: "" }; + const thrown = await callTool({ tool: FAKE_TOOL, args, library: { fake: throwRangeError } }); + const rejected = await callTool({ tool: FAKE_TOOL, args, library: { fake: rejectOffline } }); + + expect(thrown).toStrictEqual({ + content: [{ type: "text", text: "RangeError: out of range" }], + isError: true, + }); + expect(rejected).toStrictEqual({ + content: [{ type: "text", text: "Error: offline" }], + isError: true, + }); + }); + + test("should reject when the library has no function named after the tool", async () => { + const inherited: Record = Object.create({ fake: returnUndefined }); + const failures = await Promise.all( + [{}, { fake: "not a function" }, inherited].map((candidate) => catchFailure(candidate)), + ); + + expect(failures.map(String)).toStrictEqual([ + "TypeError: The library has no function named fake", + "TypeError: The library has no function named fake", + "TypeError: The library has no function named fake", + ]); + expect(failures.every((failure) => failure instanceof TypeError)).toBe(true); + }); + + test("should check the arguments only once the function is found", async () => { + expect(await catchFailure({ fake: returnUndefined })).toStrictEqual({ + content: [{ type: "text", text: "arguments.value is required" }], + isError: true, + }); + }); + }); + + describe("properties", () => { + test("should answer every offline tool, whatever the value, without an error", async () => { + const property = fc.asyncProperty(anyValueTool, fc.string(), async (tool, value) => { + const valueSchema = tool.inputSchema.properties?.["value"]; + const args = { value: valueSchema?.type === "number" ? value.length : value }; + const result = await callTool({ tool, args, library }); + const text: unknown = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(false); + expect(text === undefined).toBe(false); + }); + + await fc.assert(property); + }); + + test("should never reject on arbitrary arguments", async () => { + const property = fc.asyncProperty(anyOfflineTool, anyArguments, async (tool, args) => { + const result = await callTool({ tool, args, library }); + + expect(typeof result.isError).toBe("boolean"); + expect(typeof result.content[0].text).toBe("string"); + }); + + await fc.assert(property); + }); + }); +}); + +describe("callTool types", () => { + test("should take the tool, the arguments and the library and resolve to a tool result", () => { + expectTypeOf(callTool).parameter(0).toEqualTypeOf(); + expectTypeOf(callTool).returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<[{ type: "text"; text: string }]>(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/_mcp/call-tool/call-tool.ts b/src/_mcp/call-tool/call-tool.ts new file mode 100644 index 00000000..4900c9e0 --- /dev/null +++ b/src/_mcp/call-tool/call-tool.ts @@ -0,0 +1,73 @@ +import { type McpTool } from "../constants"; +import { parseToolArguments } from "../parse-tool-arguments/parse-tool-arguments"; +import { toJsonValue } from "../to-json-value/to-json-value"; + +/** The functions a tool may call, keyed by name: the namespace of the library entry point. */ +export type McpLibrary = Readonly>; + +/** The `content` and `isError` members of a `tools/call` result. */ +export type CallToolResult = { + /** A single text block: the JSON of the library result, or the error message. */ + content: [{ type: "text"; text: string }]; + /** Whether the call failed in a way the model can act on. */ + isError: boolean; +}; + +/** What `callTool` needs: the tool, the arguments the client sent and the library to call. */ +export type CallToolParams = { + /** The tool being called. */ + tool: McpTool; + /** The `arguments` object of the `tools/call` request. */ + args: Record; + /** The library the tool name is looked up in. */ + library: McpLibrary; +}; + +const toResult = (text: string, isError: boolean): CallToolResult => ({ + content: [{ type: "text", text }], + isError, +}); + +/** + * Runs one tool: checks the arguments against the tool's input schema, calls the library function + * of the same name and writes its result as JSON. Arguments that break the schema and an error + * the function throws or rejects with (the CEP lookups do) are tool execution errors, answered + * with `isError: true` and a message the model can act on. A value the library refuses by + * returning `false`, `""` or `null` is an ordinary result. + * + * @param {CallToolParams} params - The tool, its arguments and the library. + * @returns {Promise} The tool result. Rejects only when the library has no + * function named after the tool, which is a server fault and not a tool error. + * + * @example + * ```typescript + * await callTool({ tool, args: { value: "111.444.777-35" }, library }); + * // { content: [{ type: "text", text: "true" }], isError: false } + * ``` + * + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#error-handling + */ +export const callTool = async ({ + tool, + args, + library, +}: CallToolParams): Promise => { + const target: unknown = Object.hasOwn(library, tool.name) ? library[tool.name] : undefined; + if (typeof target !== "function") { + throw new TypeError(`The library has no function named ${tool.name}`); + } + + const parsed = parseToolArguments(tool.inputSchema, args, "arguments"); + if (!parsed.ok) return toResult(parsed.message, true); + + const { value } = parsed; + const positional = + tool.parameters === "object" ? [value] : tool.parameters.map((name) => value[name]); + + try { + const result: unknown = await Reflect.apply(target, undefined, positional); + return toResult(JSON.stringify(toJsonValue(result)), false); + } catch (error) { + return toResult(String(error), true); + } +}; diff --git a/src/_mcp/constants.ts b/src/_mcp/constants.ts new file mode 100644 index 00000000..cba06843 --- /dev/null +++ b/src/_mcp/constants.ts @@ -0,0 +1,1656 @@ +/** JSON Schema `type` values the tool table uses. */ +export type McpJsonSchemaType = "string" | "number" | "integer" | "boolean" | "object" | "array"; + +/** The JSON Schema 2020-12 subset the tool table is written in and `parseToolArguments` enforces. */ +export type McpJsonSchema = { + /** The JSON type the value must have. */ + type: McpJsonSchemaType; + /** What the value means, shown to the model. */ + description?: string; + /** The closed list of accepted values. */ + enum?: readonly (string | number)[]; + /** `"date"` marks a `YYYY-MM-DD` string that reaches the library as a local `Date`. */ + format?: "date"; + /** The properties an object accepts; any other property is refused. */ + properties?: Readonly>; + /** The properties an object must carry. */ + required?: readonly string[]; + /** Always `false`: tells the client that objects are closed. */ + additionalProperties?: false; + /** The schema of every item of an array. */ + items?: McpJsonSchema; +}; + +/** One library function exposed as an MCP tool. */ +export type McpTool = { + /** The tool name, which is the name of the library function it calls. */ + name: string; + /** What the tool does and what it returns, shown to the model. */ + description: string; + /** The `arguments` properties passed positionally, or `"object"` to pass `arguments` whole. */ + parameters: readonly string[] | "object"; + /** The JSON Schema of `arguments`. */ + inputSchema: McpJsonSchema; + /** Whether the tool queries a public web API instead of computing offline. */ + network?: true; +}; + +/** The modern, stateless protocol revision, negotiated per request through `_meta`. */ +export const MODERN_PROTOCOL_VERSION = "2026-07-28"; + +/** The handshake based revisions served after `initialize`, newest first. */ +export const LEGACY_PROTOCOL_VERSIONS: readonly string[] = [ + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +]; + +/** Every revision the server speaks, newest first. */ +export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [ + MODERN_PROTOCOL_VERSION, + ...LEGACY_PROTOCOL_VERSIONS, +]; + +/** The `_meta` key that carries the protocol version of a modern request. */ +export const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"; + +/** The `_meta` key that carries the client capabilities of a modern request. */ +export const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; + +/** The `_meta` key a modern result identifies the server under. */ +export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + +/** JSON-RPC 2.0 and MCP error codes. */ +export const ERROR_CODES = { + parseError: -32_700, + invalidRequest: -32_600, + methodNotFound: -32_601, + invalidParams: -32_602, + internalError: -32_603, + unsupportedProtocolVersion: -32_022, +} as const; + +/** How long a client may cache `server/discover` and `tools/list`: the tools never change while the process runs. */ +export const CACHE_TTL_MS = 3_600_000; + +/** The name the server reports in `serverInfo`. */ +export const SERVER_NAME = "brazilian-utils"; + +/** The guidance sent to the model with `initialize` and `server/discover`. */ +export const SERVER_INSTRUCTIONS = + "Validate, format, parse, generate and look up Brazilian data (CPF, CNPJ, CEP, boleto, Pix, phone, NF-e, holidays, banks, IBGE municipalities and more) instead of answering from memory. Every tool calls the function of the same name of the @brazilian-utils/brazilian-utils package and returns its result as JSON. An invalid value is a normal result, not an error: validators answer false, formatters and parsers answer an empty string, lookups answer null or an empty list. Documents must be passed as strings, so leading zeros survive. Dates are calendar dates written as YYYY-MM-DD. Generated documents are random and synthetic: they pass the check digit rules and belong to no one. Only getAddressInfoByCep and getCepInfoByAddress use the network."; + +const STATE_CODES = [ + "AC", + "AL", + "AM", + "AP", + "BA", + "CE", + "DF", + "ES", + "GO", + "MA", + "MG", + "MS", + "MT", + "PA", + "PB", + "PE", + "PI", + "PR", + "RJ", + "RN", + "RO", + "RR", + "RS", + "SC", + "SE", + "SP", + "TO", +] as const; + +const STATE_CODE: McpJsonSchema = { + type: "string", + enum: STATE_CODES, + description: "Two-letter code of a Brazilian state (UF).", +}; + +const LOOSE_STATE_CODE: McpJsonSchema = { + type: "string", + description: "Two-letter code of a Brazilian state (UF), in any letter case, such as SP.", +}; + +const VALUE: McpJsonSchema = { + type: "string", + description: "The value to read, with or without its mask.", +}; + +const DATE: McpJsonSchema = { + type: "string", + format: "date", + description: "Calendar date written as YYYY-MM-DD.", +}; + +const PAD: McpJsonSchema = { + type: "boolean", + description: "Left pad the value with zeros up to the full length before masking. Default false.", +}; + +const CNPJ_VERSION: McpJsonSchema = { + type: "integer", + enum: [1, 2], + description: "CNPJ format: 1 (default) is numeric only, 2 also covers the alphanumeric CNPJ.", +}; + +const PHONE_VERSION: McpJsonSchema = { + type: "integer", + enum: [1, 2], + description: + "Mobile numbering rule: 1 (default) lets the number start with 6 to 9, 2 follows Resolução Anatel 749/2022 and lets it start with 7 to 9 only.", +}; + +const INCLUDE_LEGACY: McpJsonSchema = { + type: "boolean", + description: "Also list the 8 codes a past revision of the table retired. Default false.", +}; + +const CERTIDAO_TYPES: McpJsonSchema = { + type: "array", + items: { + type: "string", + enum: [ + "birth", + "marriage", + "religious-marriage", + "death", + "stillbirth", + "banns", + "other", + "emancipation", + "interdiction", + ], + }, + description: "Types of act accepted. All of them by default.", +}; + +const NO_INPUT: McpJsonSchema = { type: "object", properties: {}, additionalProperties: false }; + +const VALUE_INPUT: McpJsonSchema = { + type: "object", + properties: { value: VALUE }, + required: ["value"], + additionalProperties: false, +}; + +const NUMBER_INPUT: McpJsonSchema = { + type: "object", + properties: { value: { type: "number", description: "The number to write out." } }, + required: ["value"], + additionalProperties: false, +}; + +const PAD_INPUT: McpJsonSchema = { + type: "object", + properties: { + value: VALUE, + options: { type: "object", properties: { pad: PAD }, additionalProperties: false }, + }, + required: ["value"], + additionalProperties: false, +}; + +const CNPJ_VERSION_INPUT: McpJsonSchema = { + type: "object", + properties: { + value: VALUE, + options: { type: "object", properties: { version: CNPJ_VERSION }, additionalProperties: false }, + }, + required: ["value"], + additionalProperties: false, +}; + +const BUSINESS_DAY_OPTIONS: McpJsonSchema = { + type: "object", + properties: { + stateCode: { ...STATE_CODE, description: "Also skip the holidays of this state (UF)." }, + includeOptional: { + type: "boolean", + description: + "Whether optional holidays (pontos facultativos such as Carnaval and Corpus Christi) count as non-business days. Default true.", + }, + }, + additionalProperties: false, +}; + +const BUSINESS_DAY_WALK_INPUT: McpJsonSchema = { + type: "object", + properties: { + date: DATE, + amount: { type: "integer", description: "How many business days to walk." }, + options: BUSINESS_DAY_OPTIONS, + }, + required: ["date", "amount"], + additionalProperties: false, +}; + +const STATE_CODE_INPUT: McpJsonSchema = { + type: "object", + properties: { stateCode: LOOSE_STATE_CODE }, + required: ["stateCode"], + additionalProperties: false, +}; + +/** The tools the server lists, sorted by name: one per public function of the library. */ +export const TOOLS: readonly McpTool[] = [ + { + name: "addBusinessDays", + description: + "Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays; a negative amount walks backwards. Returns the resulting date, or null when the walk leaves the supported years (1900 to 2099).", + parameters: ["date", "amount", "options"], + inputSchema: BUSINESS_DAY_WALK_INPUT, + }, + { + name: "capitalize", + description: + "Capitalize each word the way a Brazilian name, company name or address is written: prepositions such as de, da and dos stay lower case and known acronyms stay upper case.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "The text to capitalize." }, + options: { + type: "object", + properties: { + lowerCaseWords: { + type: "array", + items: { type: "string" }, + description: "Words kept in lower case, replacing the default list.", + }, + upperCaseWords: { + type: "array", + items: { type: "string" }, + description: "Words kept in upper case, replacing the default list.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "convertCurrencyToWords", + description: + 'Write an amount in Brazilian Reais out in Portuguese words (por extenso), the style of cheques and contracts: 1523.45 becomes "mil quinhentos e vinte e três reais e quarenta e cinco centavos". The value is truncated to 2 decimal places. Returns "" for an unsupported value.', + parameters: ["value"], + inputSchema: NUMBER_INPUT, + }, + { + name: "convertDateToWords", + description: + 'Write a date out in Brazilian Portuguese words (por extenso): "2024-01-01" becomes "primeiro de janeiro de dois mil e vinte e quatro". Returns "" for an invalid date.', + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "The date, written as YYYY-MM-DD or DD/MM/YYYY." }, + options: { + type: "object", + properties: { + style: { + type: "string", + enum: ["full", "month"], + description: + '"full" (default) writes day, month and year in words; "month" keeps the day and the year as digits and writes only the month in words.', + }, + weekday: { + type: "boolean", + description: "Start with the day of the week. Default false.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "convertLicensePlateToMercosul", + description: + 'Convert an old format Brazilian license plate (ABC1234) to the Mercosul format (ABC1C34) with the official table, where the 5th character, a digit, becomes a letter. Returns "" when the value is not a valid old format plate.', + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "convertNumberToWords", + description: + 'Write an integer out in Brazilian Portuguese cardinal words (por extenso): 1235 becomes "mil duzentos e trinta e cinco". A non-integer is truncated. Returns "" outside -999999999999999 to 999999999999999.', + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: { type: "number", description: "The number to write out." }, + options: { + type: "object", + properties: { + gender: { + type: "string", + enum: ["masculine", "feminine"], + description: + 'Grammatical gender of the words ("um"/"dois" or "uma"/"duas"). Default masculine.', + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "differenceInBusinessDays", + description: + "Count the Brazilian business days (dias úteis) between two dates, with the semantics of date-fns: the walk starts at earlierDate and stops right before laterDate, and the count is negative when laterDate comes first. Returns null for a date outside the supported years (1900 to 2099).", + parameters: ["laterDate", "earlierDate", "options"], + inputSchema: { + type: "object", + properties: { laterDate: DATE, earlierDate: DATE, options: BUSINESS_DAY_OPTIONS }, + required: ["laterDate", "earlierDate"], + additionalProperties: false, + }, + }, + { + name: "formatBoleto", + description: + "Format a boleto linha digitável, the 47 digit cobrança bancária or the 48 digit arrecadação one, with its printed mask. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCaepf", + description: + "Format a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number as 000.000.000/000-00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCei", + description: + "Format a CEI (Cadastro Específico do INSS) number as 00.000.00000/00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCep", + description: + "Format a CEP (Brazilian postal code) as 00000-000. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCertidao", + description: + "Format the 32 digit matrícula of a certidão de registro civil (birth, marriage, death) in the printed groups 6 2 2 4 1 5 3 7 2. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCnae", + description: + "Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code as 0000-0/00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCnh", + description: + "Format a CNH (driver's license) number as 000000000-00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCno", + description: + "Format a CNO (Cadastro Nacional de Obras) number as 00.000.00000/00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCnpj", + description: + "Format a CNPJ as 00.000.000/0000-00, optionally hiding the first 2 characters and the check digits. Formats as far as the characters go and does not validate.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + pad: PAD, + version: CNPJ_VERSION, + obfuscate: { + type: "boolean", + description: + "Mask as **.345.678/0001-**, the gov.br display convention. Default false.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "formatCns", + description: + "Format a CNS (Cartão Nacional de Saúde, the SUS card) number in groups of 3 4 4 4 digits. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatCpf", + description: + "Format a CPF as 000.000.000-00, optionally hiding the first 3 digits and the check digits. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + pad: PAD, + obfuscate: { + type: "boolean", + description: "Mask as ***.456.789-**, the gov.br display convention. Default false.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "formatCurrency", + description: + 'Format a number in the BRL pattern: 1234.56 becomes "1.234,56", or "R$ 1.234,56" with the symbol.', + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: { type: "number", description: "The amount, in reais." }, + options: { + type: "object", + properties: { + symbol: { type: "boolean", description: "Prefix the result with R$. Default false." }, + precision: { type: "integer", description: "Number of decimal places. Default 2." }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "formatIban", + description: + "Format an IBAN in the ISO 13616 print grouping, blocks of 4 characters, up to the 29 characters of a Brazilian IBAN. Does not validate.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "formatLegalNature", + description: + "Format a legal nature (natureza jurídica) code as 000-0. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatLicensePlate", + description: + 'Format a Brazilian license plate: an old format plate gets its hyphen (ABC-1234) and a Mercosul plate stays as ABC1D23. Returns "" for a value that cannot start a valid plate.', + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "formatNcm", + description: + "Format an NCM (Nomenclatura Comum do Mercosul) code as 0000.00.00. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatNfeKey", + description: + "Format a 44 digit DF-e access key (chave de acesso of an NF-e, NFC-e, CT-e, MDF-e and the like) in groups of 4 digits, the way the DANFE prints it. Does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatPassport", + description: + "Format a Brazilian passport number: upper case, without symbols, capped to 8 characters. Does not validate.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "formatPhone", + description: + "Format a Brazilian phone number with the chosen mask. Does not validate; use isValidPhone for that.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + mask: { + type: "string", + enum: ["auto", "e164", "international", "service", "sn", "nanp"], + description: + '"sn" (default) is the subscriber number without the DDD, "nanp" is (11) 98765-4321, "e164" is +5511987654321, "international" is +55 11 98765-4321, "service" is for numbers such as 0800, and "auto" picks the mask from the digits given.', + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "formatPis", + description: + "Format a PIS/PASEP/NIS number as 000.00000.00-0. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatProcessoJuridico", + description: + "Format a processo jurídico (lawsuit) number in the CNJ mask NNNNNNN-DD.AAAA.J.TR.OOOO. Formats as far as the digits go and does not validate.", + parameters: ["value", "options"], + inputSchema: PAD_INPUT, + }, + { + name: "formatVoterId", + description: + "Format a título de eleitor (voter ID) number as 0000 0000 00 00, or with the 13 digit grouping São Paulo and Minas Gerais may use. Does not validate.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "generateBoleto", + description: + "Generate a valid random boleto linha digitável, for tests and examples: a cobrança bancária one (47 digits) by default, or an arrecadação one (48 digits, starting with 8).", + parameters: "object", + inputSchema: { + type: "object", + properties: { + type: { + type: "string", + enum: ["bancario", "arrecadacao"], + description: "Kind of boleto. Default bancario.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generateCep", + description: "Generate a random CEP (Brazilian postal code), 8 digits, for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generateCnh", + description: "Generate a valid random CNH (driver's license) number, for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generateCnpj", + description: + "Generate a valid random CNPJ, 14 characters without mask, for tests and examples. It is synthetic and belongs to no company.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + version: { + type: "integer", + enum: [1, 2], + description: "1 (default) generates a numeric CNPJ, 2 an alphanumeric one.", + }, + branch: { + type: "integer", + description: + "The número de ordem (filial) block in positions 9 to 12, from 1 to 9999. Random by default.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generateCpf", + description: + "Generate a valid random CPF, 11 digits without mask, for tests and examples. It is synthetic and belongs to no one.", + parameters: ["stateCode"], + inputSchema: { + type: "object", + properties: { + stateCode: { + ...STATE_CODE, + description: + "Ties the CPF to the região fiscal of this state (the 9th digit). Random by default.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generateLegalNature", + description: + "Generate a random legal nature (natureza jurídica) code out of the 92 codes in force, for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generateLicensePlate", + description: "Generate a random Brazilian license plate, for tests and examples.", + parameters: ["format"], + inputSchema: { + type: "object", + properties: { + format: { + type: "string", + enum: ["LLLNNNN", "LLLNLNN"], + description: + "LLLNNNN is the old format (ABC1234) and LLLNLNN the Mercosul format (ABC1D23). Random by default.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generatePassport", + description: + "Generate a random Brazilian passport number (2 letters and 6 digits), for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generatePhone", + description: + "Generate a random Brazilian phone number, digits only, for tests and examples. A mobile or a landline number by default; a service number has no DDD.", + parameters: ["type"], + inputSchema: { + type: "object", + properties: { + type: { + type: "string", + enum: ["mobile", "landline", "service"], + description: "Kind of number. Mobile or landline, at random, by default.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generatePis", + description: "Generate a valid random PIS/PASEP/NIS number, for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generatePixPayload", + description: + 'Generate the payload of a Pix BR Code, the text behind a Pix QR Code and "Pix copia e cola". Give exactly one of key (static payload) or url (dynamic payload); returns null when both or neither are given, or when a field is invalid.', + parameters: "object", + inputSchema: { + type: "object", + properties: { + key: { + type: "string", + description: + "The Pix key: a CPF, a CNPJ, an e-mail, a mobile phone or a random key (EVP).", + }, + url: { + type: "string", + description: + "The PSP location of a dynamic payload, without the scheme, such as pix.example.com/qr/v2/1234. At most 77 characters.", + }, + merchantName: { type: "string", description: "Name of the receiver." }, + merchantCity: { type: "string", description: "City of the receiver." }, + amount: { type: "number", description: "Amount in reais. Static payload only." }, + txid: { type: "string", description: "Transaction identifier. Static payload only." }, + description: { type: "string", description: "Free text shown to the payer." }, + }, + required: ["merchantName", "merchantCity"], + additionalProperties: false, + }, + }, + { + name: "generateProcessoJuridico", + description: + "Generate a valid random processo jurídico (lawsuit) number in the CNJ layout, 20 digits, for tests and examples. Returns null for a year or a court out of range.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + year: { + type: "integer", + description: + "Year of filing, from the current year to 9999. The current year by default.", + }, + court: { + type: "integer", + description: "The órgão do Judiciário digit (J), from 1 to 9. Random by default.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "generateRenavam", + description: "Generate a valid random RENAVAM, 11 digits, for tests and examples.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "generateVoterId", + description: + "Generate a valid random título de eleitor (voter ID) number, 12 digits, for tests and examples.", + parameters: ["stateCode"], + inputSchema: { + type: "object", + properties: { + stateCode: { + type: "string", + enum: [...STATE_CODES, "ZZ"], + description: "State (UF) that issued the ID, or ZZ for one issued abroad. Default ZZ.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "getAddressInfoByCep", + description: + "Look the address of a CEP up on public web APIs (ViaCEP and BrasilAPI by default, first answer wins). Returns cep, state, city, neighborhood and street; an invalid or unknown CEP and an unreachable service are reported as errors.", + parameters: ["value", "options"], + network: true, + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "The CEP, 8 digits, with or without the hyphen." }, + options: { + type: "object", + properties: { + providers: { + type: "array", + items: { type: "string", enum: ["viacep", "widenet", "brasilapi"] }, + description: + "Providers to query. Default viacep and brasilapi; widenet no longer answers.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "getAreaCodeInfo", + description: + "Get the state and the region a Brazilian DDD (area code) belongs to. Returns null for a DDD that is not in use.", + parameters: ["value"], + inputSchema: { + type: "object", + properties: { value: { type: "string", description: "The DDD, 2 digits, such as 11." } }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "getAreaCodesByState", + description: + "Get every DDD (area code) that serves a Brazilian state, in ascending order. Returns an empty list for an unknown state.", + parameters: ["stateCode"], + inputSchema: STATE_CODE_INPUT, + }, + { + name: "getBankByCode", + description: + "Look a Brazilian bank up by its 3 digit compensation code (COMPE) in the Banco Central STR participants list. Returns code, ispb and name, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getBankByIspb", + description: + "Look a Brazilian bank up by its 8 digit ISPB in the Banco Central STR participants list. Returns code, ispb and name, or null; only institutions that also have a COMPE code are listed.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getBanks", + description: + "List every Brazilian bank that has a compensation code (COMPE), from the Banco Central STR participants list, each with code, ispb and name. The list has a few hundred entries; prefer getBankByCode or getBankByIspb for a single bank.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "getBoletoInfo", + description: + "Read the fields of a boleto: amount in cents, expiration date and bank code, plus segment and value kind for an arrecadação one. Returns null when the boleto is not valid.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + referenceDate: { + ...DATE, + description: + "The date the boleto is read on, as YYYY-MM-DD, which settles the expiration date now that the fator de vencimento restarted in 2025. Today by default.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "getCbo", + description: + "Look a CBO (Classificação Brasileira de Ocupações) code up and get the official occupation title. Returns code and description, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getCepInfoByAddress", + description: + "Search the CEPs of an address on the ViaCEP public web API. Returns a list of ViaCEP records (cep, logradouro, bairro, localidade, uf and more); an invalid query, no match and an unreachable service are reported as errors.", + parameters: "object", + network: true, + inputSchema: { + type: "object", + properties: { + federalUnit: LOOSE_STATE_CODE, + city: { type: "string", description: "City name; ViaCEP needs at least 3 characters." }, + street: { type: "string", description: "Street name; ViaCEP needs at least 3 characters." }, + }, + required: ["federalUnit", "city", "street"], + additionalProperties: false, + }, + }, + { + name: "getCertidaoInfo", + description: + "Read the fields of the 32 digit matrícula of a certidão de registro civil: registry CNS, acervo, service, year, type of act (birth, marriage, death and others), book, page, term and check digits. Returns null when the matrícula is not valid.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getCfop", + description: + "Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its official description. Returns code and description, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getCnae", + description: + "Look a CNAE subclass code up in the IBGE CNAE-Subclasses 2.3 table and get its official description. Returns code and description, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getFormatLicensePlate", + description: + "Detect the format of a Brazilian license plate: LLLNNNN (old format) or LLLNLNN (Mercosul). Returns null when the plate is not valid.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getHolidays", + description: + "List the Brazilian holidays of a year, from 1900 to 2099: the national ones, the movable ones (Carnaval, Sexta-feira Santa, Corpus Christi) and, with stateCode, the holidays of that state. Each entry has name, date and type (national, state, optional or religious). Municipal holidays are not covered.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + year: { type: "integer", description: "The year, from 1900 to 2099." }, + stateCode: { ...STATE_CODE, description: "Also list the holidays of this state (UF)." }, + }, + required: ["year"], + additionalProperties: false, + }, + }, + { + name: "getIbanInfo", + description: + "Read the fields of a Brazilian IBAN: country code, check digits, bank ISPB, branch, account, account type and owner indicator. Returns null when the IBAN is not a valid Brazilian one.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getLegalNature", + description: + "Look a legal nature (natureza jurídica) code up in the IBGE/CONCLA table. Returns code, description, category and whether the code is a retired (legacy) one, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getLegalNatures", + description: + "Get the legal nature (natureza jurídica) table of IBGE/CONCLA as a map of code to description: the 92 codes in force.", + parameters: "object", + inputSchema: { + type: "object", + properties: { includeLegacy: INCLUDE_LEGACY }, + additionalProperties: false, + }, + }, + { + name: "getLegalNaturesByCategory", + description: + "List the legal natures of a CONCLA category: 1 Administração Pública, 2 Entidades Empresariais, 3 Entidades sem Fins Lucrativos, 4 Pessoas Físicas, 5 Organizações Internacionais. Returns an empty list for an unknown category.", + parameters: ["category", "options"], + inputSchema: { + type: "object", + properties: { + category: { + type: "string", + enum: ["1", "2", "3", "4", "5"], + description: "The category, the first digit of the code.", + }, + options: { + type: "object", + properties: { includeLegacy: INCLUDE_LEGACY }, + additionalProperties: false, + }, + }, + required: ["category"], + additionalProperties: false, + }, + }, + { + name: "getMunicipalities", + description: + "List the municipalities of a Brazilian state published by the IBGE, sorted by name, each with its 7 digit IBGE code, name and stateCode. The state is required here, since the whole country has 5571 entries.", + parameters: ["stateCode"], + inputSchema: { + type: "object", + properties: { stateCode: STATE_CODE }, + required: ["stateCode"], + additionalProperties: false, + }, + }, + { + name: "getMunicipalityByCode", + description: + "Look a Brazilian municipality up by its 7 digit IBGE code. Returns code, name and stateCode, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getNfeKeyInfo", + description: + "Read the fields of a 44 digit DF-e access key (NF-e, NFC-e, CT-e, MDF-e and the like): state, year, month, issuer CNPJ or CPF, model, series, number, emission type, code and check digit. Returns null when the key is not valid.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getPixKeyInfo", + description: + "Identify a Pix key (cpf, cnpj, email, phone or evp) and normalize it to the form the DICT expects. Returns type and value, or null when it is not a valid key. It does not tell whether the key is registered.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getPixPayloadInfo", + description: + 'Read the fields of a Pix BR Code payload ("Pix copia e cola"): key or url, merchant name and city, amount, txid, description and whether it is static or dynamic. Returns null when the payload is malformed or its CRC is wrong.', + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getStateByIbgeCode", + description: + "Get the Brazilian state of a 2 digit IBGE code (cUF), the code that opens every NF-e access key and every IBGE municipality code. Returns code, name, region and ibgeCode, or null.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "getStateCodeByName", + description: + 'Get the two-letter code (UF) of a Brazilian state from its name, ignoring accents and letter case: "sao paulo" gives "SP". Returns null for an unknown name.', + parameters: ["name"], + inputSchema: { + type: "object", + properties: { name: { type: "string", description: "The state name." } }, + required: ["name"], + additionalProperties: false, + }, + }, + { + name: "getStateNameByCode", + description: + 'Get the name of a Brazilian state from its two-letter code (UF): "SP" gives "São Paulo". Returns null for an unknown code.', + parameters: ["stateCode"], + inputSchema: STATE_CODE_INPUT, + }, + { + name: "getStates", + description: + "List the 27 Brazilian states (the Distrito Federal included), sorted by name, each with its code, name, region code, region name and 2 digit IBGE code.", + parameters: [], + inputSchema: NO_INPUT, + }, + { + name: "getTimezoneByState", + description: + "Get the IANA time zone of a Brazilian state, the zone of its capital, such as America/Sao_Paulo. Returns null for an unknown state.", + parameters: ["stateCode"], + inputSchema: STATE_CODE_INPUT, + }, + { + name: "isBusinessDay", + description: + "Check whether a date is a Brazilian business day (dia útil): not a Saturday, a Sunday or a Brazilian holiday. Municipal holidays are not covered.", + parameters: ["date", "options"], + inputSchema: { + type: "object", + properties: { date: DATE, options: BUSINESS_DAY_OPTIONS }, + required: ["date"], + additionalProperties: false, + }, + }, + { + name: "isHoliday", + description: + "Check whether a date is a Brazilian holiday: national, or of a state when stateCode is given. Municipal holidays are not covered.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + targetDate: DATE, + stateCode: { ...STATE_CODE, description: "Also consider the holidays of this state (UF)." }, + }, + required: ["targetDate"], + additionalProperties: false, + }, + }, + { + name: "isValidBankAccount", + description: + "Check a Brazilian bank account: the bank must exist in the Banco Central list, and the agency, the account and the check digit must fit the bank. The check digit algorithm is verified for the banks that publish one (Banco do Brasil, Bradesco, Itaú, Santander and others); for the others only the structure is checked.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + bankCode: { + type: "string", + description: "The 3 digit compensation code (COMPE) of the bank.", + }, + agency: { type: "string", description: "The agency number, without its check digit." }, + account: { type: "string", description: "The account number, without its check digit." }, + digit: { type: "string", description: "The check digit of the account." }, + }, + required: ["bankCode", "agency", "account", "digit"], + additionalProperties: false, + }, + }, + { + name: "isValidBoleto", + description: + "Check whether a boleto is valid, check digits included: the 47 digit cobrança bancária linha digitável, or the arrecadação one as its 48 digit linha digitável or its 44 digit barcode.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCaepf", + description: + "Check whether a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number, 14 digits, is valid, check digits included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCbo", + description: + "Check whether a CBO (Classificação Brasileira de Ocupações) code exists in the official occupation table.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCei", + description: + "Check whether a CEI (Cadastro Específico do INSS) number, 12 digits, is valid, check digit included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCep", + description: + "Check whether a CEP (Brazilian postal code) is well formed: 8 digits, with or without the hyphen. It does not tell whether the CEP exists; use getAddressInfoByCep for that.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCertidao", + description: + "Check whether the 32 digit matrícula of a certidão de registro civil (birth, marriage, death and other acts) is valid, check digits included.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { accept: CERTIDAO_TYPES }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "isValidCfop", + description: + "Check whether a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table in force.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCnae", + description: + "Check whether a CNAE subclass code exists in the IBGE CNAE-Subclasses 2.3 table, with or without the 0000-0/00 mask.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCnh", + description: + "Check whether a CNH (driver's license) number, 11 digits, is valid, check digits included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCno", + description: + "Check whether a CNO (Cadastro Nacional de Obras) number, 12 digits, is valid, check digit included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCnpj", + description: + "Check whether a CNPJ is valid, check digits included, with or without its mask. It does not tell whether the CNPJ is registered at the Receita Federal.", + parameters: ["value", "options"], + inputSchema: CNPJ_VERSION_INPUT, + }, + { + name: "isValidCns", + description: + "Check whether a CNS (Cartão Nacional de Saúde, the SUS card) number, 15 digits, is valid, definitive and provisional cards alike.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCpf", + description: + "Check whether a CPF is valid, check digits included, with or without its mask. It does not tell whether the CPF is registered at the Receita Federal.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCreditCard", + description: + "Check a payment card number with the Luhn algorithm (ISO/IEC 7812-1). No brand detection and no issuer lookup.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCsosn", + description: + "Check whether a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 official codes.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidCst", + description: + "Check whether a CST (Código de Situação Tributária) code is valid for a tax: 3 digits (origin plus CST) for ICMS, 2 digits for IPI, PIS and COFINS.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + tax: { + type: "string", + enum: ["icms", "ipi", "pis", "cofins"], + description: + "The tax whose table is read. By default a code of any of the four tables is accepted.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "isValidEmail", + description: + "Check whether an e-mail address is well formed, by a practical subset of the WHATWG HTML definition. It does not tell whether the mailbox exists.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidIban", + description: + "Check whether a Brazilian IBAN (BR, 29 characters) is valid, ISO 7064 MOD 97-10 check digits included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidIe", + description: + "Check whether an inscrição estadual (state tax registration) is valid under the rules of its state, check digits included.", + parameters: "object", + inputSchema: { + type: "object", + properties: { value: VALUE, stateCode: LOOSE_STATE_CODE }, + required: ["value", "stateCode"], + additionalProperties: false, + }, + }, + { + name: "isValidLandlinePhone", + description: "Check whether a Brazilian landline phone number, DDD included, is valid.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidLegalNature", + description: + "Check whether a legal nature (natureza jurídica) code exists in the IBGE/CONCLA table, retired codes included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidLicensePlate", + description: + "Check whether a Brazilian license plate is valid, in the old format (ABC-1234) or in the Mercosul format (ABC1D23).", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidMobilePhone", + description: "Check whether a Brazilian mobile phone number, DDD included, is valid.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { version: PHONE_VERSION }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "isValidNcm", + description: + "Check whether an NCM (Nomenclatura Comum do Mercosul) code, 8 digits, exists in the table published by Siscomex.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidNfeKey", + description: + "Check whether a 44 digit DF-e access key (chave de acesso of an NF-e, NFC-e, CT-e, MDF-e, CT-e OS, GTV-e, BP-e, NF3e or NFCom) is valid, check digit included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidPassport", + description: + "Check whether a Brazilian passport number is well formed: 2 letters followed by 6 digits.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidPhone", + description: + "Check whether a Brazilian phone number, DDD included, is valid. A +55 country code is accepted. Mobile and landline numbers are accepted by default.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + version: PHONE_VERSION, + accept: { + type: "array", + items: { type: "string", enum: ["mobile", "landline", "service"] }, + description: "Kinds of number accepted. Default mobile and landline.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "isValidPis", + description: "Check whether a PIS/PASEP/NIS number, 11 digits, is valid, check digit included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidPixKey", + description: + "Check whether a Pix key is well formed: a CPF, a CNPJ, an e-mail, a mobile phone or a random key (EVP). It does not tell whether the key is registered in the DICT.", + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: VALUE, + options: { + type: "object", + properties: { + accept: { + type: "array", + items: { type: "string", enum: ["cpf", "cnpj", "email", "phone", "evp"] }, + description: "Kinds of key accepted. All of them by default.", + }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "isValidPixPayload", + description: + 'Check whether a Pix BR Code payload ("Pix copia e cola") is valid: TLV structure, mandatory fields, the Pix account template and the CRC-16.', + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidProcessoJuridico", + description: + "Check whether a processo jurídico (lawsuit) number is valid under the CNJ layout NNNNNNN-DD.AAAA.J.TR.OOOO: check digits and an existing órgão and tribunal.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidRegistroProfissional", + description: + "Check the structure of a professional council registration number (OAB, CRM, CRO, CRP or CRC). It does not tell whether the registration exists.", + parameters: "object", + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "The registration number, such as 123456/SP." }, + council: { + type: "string", + enum: ["OAB", "CRM", "CRO", "CRP", "CRC"], + description: "The council that issued the registration.", + }, + stateCode: { + ...STATE_CODE, + description: "The state (UF) the registration must belong to.", + }, + }, + required: ["value", "council"], + additionalProperties: false, + }, + }, + { + name: "isValidRenavam", + description: "Check whether a RENAVAM, 9 or 11 digits, is valid, check digit included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidServicePhone", + description: + "Check whether a number is a Brazilian service number, dialed without a DDD: 0300, 0500, 0800 and 0900 numbers, 300X and 400X numbers and the 3 digit public utility codes such as 190.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidVin", + description: + "Check whether a VIN (chassi), 17 characters, is valid: no I, O or Q, and the 9th position check digit of 49 CFR 565.15. That check digit is a North American rule that the Brazilian norms do not mandate, so a genuine Brazilian VIN may fail it.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "isValidVoterId", + description: + "Check whether a título de eleitor (voter ID) number, 12 digits or 13 for São Paulo and Minas Gerais, is valid, check digits included.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseBoleto", + description: + "Remove the mask of a boleto and keep its digits, at most 47 (48 for an arrecadação one).", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCaepf", + description: "Remove the mask of a CAEPF number and keep its digits, at most 14.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCbo", + description: "Remove the mask of a CBO code and keep its digits, at most 6.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCei", + description: "Remove the mask of a CEI number and keep its digits, at most 12.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCep", + description: "Remove the mask of a CEP and keep its digits, at most 8.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCertidao", + description: + "Remove the mask of the matrícula of a certidão de registro civil and keep its digits, at most 32.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCfop", + description: "Remove the mask of a CFOP code and keep its digits, at most 4.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCnae", + description: "Remove the mask of a CNAE subclass code and keep its digits, at most 7.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCnh", + description: "Remove the mask of a CNH number and keep its digits, at most 11.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCno", + description: "Remove the mask of a CNO number and keep its digits, at most 12.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCnpj", + description: + "Remove the mask of a CNPJ and keep its characters, at most 14: digits only, or letters and digits with version 2.", + parameters: ["value", "options"], + inputSchema: CNPJ_VERSION_INPUT, + }, + { + name: "parseCns", + description: "Remove the mask of a CNS number and keep its digits, at most 15.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCpf", + description: "Remove the mask of a CPF and keep its digits, at most 11.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseCurrency", + description: + 'Read an amount written in the BRL pattern as a number: "R$ 1.234,56" gives 1234.56. A value written without any separator is read as cents: "12345" gives 123.45.', + parameters: ["value", "options"], + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "The amount as written." }, + options: { + type: "object", + properties: { + precision: { type: "integer", description: "Number of decimal places. Default 2." }, + }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "parseIban", + description: + "Remove the mask of an IBAN and keep its letters and digits in upper case, at most 29.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseLegalNature", + description: "Remove the mask of a legal nature code and keep its digits, at most 4.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseLicensePlate", + description: + "Remove the separators of a license plate and upper case it, at most 7 characters.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseNcm", + description: "Remove the mask of an NCM code and keep its digits, at most 8.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseNfeKey", + description: + "Remove the mask of a DF-e access key, and the NFe, CTe, MDFe, BPe, NF3e or NFCom prefix of the XML Id, and keep its digits, at most 44.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parsePassport", + description: + "Remove every symbol of a passport number and upper case it, at most 8 characters.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parsePhone", + description: + "Remove the mask of a Brazilian phone number and keep its digits, at most 11. A 55 country code is dropped when a 10 or 11 digit national number is left.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parsePis", + description: "Remove the mask of a PIS/PASEP/NIS number and keep its digits, at most 11.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseProcessoJuridico", + description: "Remove the mask of a processo jurídico number and keep its digits, at most 20.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "parseVoterId", + description: + "Remove the mask of a título de eleitor number and keep its digits, at most 12 (13 for São Paulo and Minas Gerais).", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "removeAccents", + description: + 'Remove the accents, tildes and cedillas of a text: "São João" becomes "Sao Joao".', + parameters: ["value"], + inputSchema: { + type: "object", + properties: { value: { type: "string", description: "The text." } }, + required: ["value"], + additionalProperties: false, + }, + }, + { + name: "subBusinessDays", + description: + "Subtract a number of Brazilian business days (dias úteis) from a date, skipping Saturdays, Sundays and Brazilian holidays. Returns the resulting date, or null when the walk leaves the supported years (1900 to 2099).", + parameters: ["date", "amount", "options"], + inputSchema: BUSINESS_DAY_WALK_INPUT, + }, +]; diff --git a/src/_mcp/handle-message/handle-message.test.ts b/src/_mcp/handle-message/handle-message.test.ts new file mode 100644 index 00000000..1e6940a8 --- /dev/null +++ b/src/_mcp/handle-message/handle-message.test.ts @@ -0,0 +1,580 @@ +import * as fc from "fast-check"; + +import { anyValue } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type McpTool, SERVER_INSTRUCTIONS } from "../constants"; +import { + handleMessage, + type HandleMessageResult, + type JsonRpcResponse, + type McpContext, + type McpSession, +} from "./handle-message"; + +const VALUE_INPUT: McpTool["inputSchema"] = { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, +}; + +const TOOLS: McpTool[] = [ + { + name: "shout", + description: "Upper cases a text.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, + { + name: "lookup", + description: "Looks a text up on the web.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + network: true, + }, + { + name: "missing", + description: "Has no function.", + parameters: ["value"], + inputSchema: VALUE_INPUT, + }, +]; + +const CONTEXT: McpContext = { + serverInfo: { name: "brazilian-utils", version: "9.9.9" }, + tools: TOOLS, + library: { + shout: (value: string): string => value.toUpperCase(), + lookup: (value: string): Promise => Promise.reject(new Error(`offline: ${value}`)), + }, +}; + +const FRESH: McpSession = { protocolVersion: null }; +const LEGACY: McpSession = { protocolVersion: "2025-06-18" }; + +const MODERN_META = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "ExampleClient", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, +}; + +const SERVER_META = { + "io.modelcontextprotocol/serverInfo": { name: "brazilian-utils", version: "9.9.9" }, +}; + +const SUPPORTED = ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + +const LISTED_TOOLS = [ + { + name: "shout", + description: "Upper cases a text.", + inputSchema: VALUE_INPUT, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + { + name: "lookup", + description: "Looks a text up on the web.", + inputSchema: VALUE_INPUT, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + { + name: "missing", + description: "Has no function.", + inputSchema: VALUE_INPUT, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, +]; + +const respond = (message: unknown, session = FRESH): Promise => + handleMessage(message, session, CONTEXT).response; + +const respondAll = (messages: unknown[], session = FRESH): Promise<(JsonRpcResponse | null)[]> => + Promise.all(messages.map((message) => respond(message, session))); + +const request = (method: string, params?: unknown, id: unknown = 1): Record => ({ + jsonrpc: "2.0", + id, + method, + ...(params === undefined ? {} : { params }), +}); + +const initialize = (protocolVersion: unknown): Record => + request("initialize", { + protocolVersion, + capabilities: {}, + clientInfo: { name: "ExampleClient", version: "1.0.0" }, + }); + +const initializeAll = async ( + versions: string[], +): Promise<{ sessions: McpSession[]; responses: (JsonRpcResponse | null)[] }> => { + const handled = versions.map((version) => handleMessage(initialize(version), FRESH, CONTEXT)); + + return { + sessions: handled.map(({ session }) => session), + responses: await Promise.all(handled.map(({ response }) => response)), + }; +}; + +const failure = (code: number, message: string, id?: number | string): JsonRpcResponse => ({ + jsonrpc: "2.0", + ...(id === undefined ? {} : { id }), + error: { code, message }, +}); + +const MISSING_VERSION = + 'Missing _meta["io.modelcontextprotocol/protocolVersion"]: send it with every request (2026-07-28), or open with initialize (2025-11-25 and earlier)'; + +const MISSING_CAPABILITIES = + 'Missing _meta["io.modelcontextprotocol/clientCapabilities"]: it must be an object, empty when the client has no optional capability'; + +const anyMethod = fc.oneof( + fc.constantFrom("initialize", "ping", "server/discover", "tools/list", "tools/call"), + anyValue, +); + +const anyToolName = fc.oneof(fc.constantFrom("shout", "lookup", "missing"), anyValue); + +const anyMeta = fc.oneof(anyValue, fc.constant(MODERN_META)); + +const anyParamsRecord = fc.record( + { + name: anyToolName, + arguments: anyValue, + protocolVersion: anyValue, + cursor: anyValue, + _meta: anyMeta, + }, + { requiredKeys: [] }, +); + +const anyRequestId = fc.oneof(fc.integer(), fc.string()); + +const anyId = fc.oneof(fc.integer(), fc.string(), fc.constantFrom(null, 1.5)); + +const anyEnvelope = fc.record( + { + jsonrpc: fc.constantFrom("2.0", "1.0", 2), + id: anyId, + method: anyMethod, + params: fc.oneof(anyValue, anyParamsRecord), + }, + { requiredKeys: [] }, +); + +const anyMessage = fc.oneof(anyValue, anyEnvelope); + +describe("handleMessage", () => { + describe("JSON-RPC envelope", () => { + test("should answer anything that is not an object with an invalid request error without id", async () => { + const messages = [null, undefined, 1, "ping", true, [], [request("ping")]]; + const expected = failure(-32_600, "The message must be an object"); + + expect(await respondAll(messages)).toStrictEqual(messages.map(() => expected)); + }); + + test("should answer a wrong jsonrpc version or a method that is not a string, with the id when it is readable", async () => { + const message = 'Expected jsonrpc "2.0" and a string method'; + const responses = await respondAll([ + { jsonrpc: "1.0", id: 7, method: "ping" }, + { id: "a", method: "ping" }, + { jsonrpc: "2.0", id: 7, method: 5 }, + { jsonrpc: "2.0", id: null }, + {}, + ]); + + expect(responses).toStrictEqual([ + failure(-32_600, message, 7), + failure(-32_600, message, "a"), + failure(-32_600, message, 7), + failure(-32_600, message), + failure(-32_600, message), + ]); + }); + + test("should stay silent on a response, whatever its shape", async () => { + const responses = await respondAll([ + { jsonrpc: "2.0", id: 1, result: {} }, + { jsonrpc: "2.0", id: 1, error: { code: 1, message: "x" } }, + { id: 1, result: null }, + ]); + + expect(responses).toStrictEqual([null, null, null]); + }); + + test("should stay silent on every notification, known or not", async () => { + const responses = await respondAll([ + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }, + { jsonrpc: "2.0", method: "tools/list" }, + { jsonrpc: "2.0", method: "unknown", params: [] }, + ]); + + expect(responses).toStrictEqual([null, null, null, null]); + }); + + test("should refuse an id that is neither a string nor an integer, without echoing it", async () => { + const messages = [null, 1.5, {}, [], true].map((id) => request("ping", undefined, id)); + const expected = failure(-32_600, "The id must be a string or an integer"); + + expect(await respondAll(messages)).toStrictEqual(messages.map(() => expected)); + }); + + test("should accept a string id, an integer id, zero and the empty string", async () => { + const messages = ["abc", 0, "", -7].map((id) => request("ping", undefined, id)); + + expect(await respondAll(messages)).toStrictEqual([ + { jsonrpc: "2.0", id: "abc", result: {} }, + { jsonrpc: "2.0", id: 0, result: {} }, + { jsonrpc: "2.0", id: "", result: {} }, + { jsonrpc: "2.0", id: -7, result: {} }, + ]); + }); + + test("should refuse params that is not an object", async () => { + const messages = [[], "a", 1, null].map((params) => request("tools/list", params)); + const expected = failure(-32_602, "params must be an object", 1); + + expect(await respondAll(messages)).toStrictEqual(messages.map(() => expected)); + }); + + test("should answer an unknown method with method not found, before looking at the version", async () => { + const responses = await respondAll([ + request("resources/list", { _meta: MODERN_META }), + request("toString"), + ]); + + expect(responses).toStrictEqual([ + failure(-32_601, "Method not found: resources/list", 1), + failure(-32_601, "Method not found: toString", 1), + ]); + }); + }); + + describe("initialize (2025-11-25 and earlier)", () => { + test("should echo every handshake revision the server speaks and open the session under it", async () => { + const versions = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + const { sessions, responses } = await initializeAll(versions); + + expect(sessions).toStrictEqual(versions.map((protocolVersion) => ({ protocolVersion }))); + expect(responses).toStrictEqual( + versions.map((protocolVersion) => ({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "brazilian-utils", version: "9.9.9" }, + instructions: SERVER_INSTRUCTIONS, + }, + })), + ); + }); + + test("should offer the newest handshake revision for a revision it does not speak, the modern one included", async () => { + const versions = ["1999-01-01", "2026-07-28", "", "toString"]; + const { sessions, responses } = await initializeAll(versions); + + expect(sessions).toStrictEqual(versions.map(() => ({ protocolVersion: "2025-11-25" }))); + expect(responses.map((response) => response?.result?.["protocolVersion"])).toStrictEqual( + versions.map(() => "2025-11-25"), + ); + }); + + test("should refuse a protocolVersion that is not a string and leave the session as it was", async () => { + const messages = [ + request("initialize", {}), + request("initialize", { protocolVersion: 20_251_125 }), + request("initialize", { protocolVersion: null }), + request("initialize"), + ]; + const handled = messages.map((message) => handleMessage(message, LEGACY, CONTEXT)); + const responses = await Promise.all(handled.map(({ response }) => response)); + const expected = failure(-32_602, "params.protocolVersion must be a string", 1); + + expect(handled.every(({ session }) => session === LEGACY)).toBe(true); + expect(responses).toStrictEqual(messages.map(() => expected)); + }); + + test("should not touch the session object it is given", () => { + const session: McpSession = { protocolVersion: null }; + const next = handleMessage(initialize("2025-06-18"), session, CONTEXT).session; + + expect(session).toStrictEqual({ protocolVersion: null }); + expect(next).not.toBe(session); + }); + + test("should keep the session for every other message", () => { + const messages = [ + request("ping"), + request("tools/list"), + request("tools/call", { name: "shout" }), + request("server/discover"), + request("nope"), + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 1, result: {} }, + { jsonrpc: "2.0", id: 1.5, method: "ping" }, + { jsonrpc: "2.0", id: 1, method: "ping", params: [] }, + null, + ]; + const sessions = messages.map((message) => handleMessage(message, LEGACY, CONTEXT).session); + + expect(sessions.every((session) => session === LEGACY)).toBe(true); + }); + }); + + describe("ping", () => { + test("should answer with an empty result before and after the handshake", async () => { + expect(await respond(request("ping"))).toStrictEqual({ jsonrpc: "2.0", id: 1, result: {} }); + expect(await respond(request("ping"), LEGACY)).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: {}, + }); + }); + }); + + describe("version negotiation", () => { + test("should refuse a request that neither carries a version nor follows initialize", async () => { + const messages = [ + request("server/discover"), + request("tools/list"), + request("tools/call"), + request("tools/list", { _meta: {} }), + request("tools/list", { _meta: "2026-07-28" }), + request("tools/list", { _meta: { progressToken: "abc" } }), + ]; + const expected = failure(-32_602, MISSING_VERSION, 1); + + expect(await respondAll(messages)).toStrictEqual(messages.map(() => expected)); + }); + + test("should answer a version it does not speak with the versions it does", async () => { + const versions = ["1900-01-01", "2027-01-01", 20_260_728, null, ["2026-07-28"]]; + const messages = versions.map((version) => + request("server/discover", { + _meta: { ...MODERN_META, "io.modelcontextprotocol/protocolVersion": version }, + }), + ); + const echoed = ["1900-01-01", "2027-01-01", "20260728", "null", "2026-07-28"]; + + expect(await respondAll(messages, LEGACY)).toStrictEqual( + echoed.map((requested) => ({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32_022, + message: "Unsupported protocol version", + data: { supported: SUPPORTED, requested }, + }, + })), + ); + }); + + test("should require the client capabilities of a modern request to be an object", async () => { + const messages = [undefined, null, "none", []].map((capabilities) => + request("tools/list", { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + }), + ); + const expected = failure(-32_602, MISSING_CAPABILITIES, 1); + + expect(await respondAll(messages, LEGACY)).toStrictEqual(messages.map(() => expected)); + }); + + test("should serve a handshake revision named in _meta in the shape of that revision, session or not", async () => { + const meta = { "io.modelcontextprotocol/protocolVersion": "2025-11-25" }; + + expect(await respond(request("tools/list", { _meta: meta }))).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: { tools: LISTED_TOOLS }, + }); + }); + }); + + describe("server/discover (2026-07-28)", () => { + test("should advertise the versions, the capabilities, the identity and the cache hints", async () => { + const message = request("server/discover", { _meta: MODERN_META }, "discover-1"); + + expect(await respond(message)).toStrictEqual({ + jsonrpc: "2.0", + id: "discover-1", + result: { + resultType: "complete", + supportedVersions: SUPPORTED, + capabilities: { tools: {} }, + instructions: SERVER_INSTRUCTIONS, + ttlMs: 3_600_000, + cacheScope: "public", + _meta: SERVER_META, + }, + }); + }); + + test("should not exist for a handshake session or a handshake revision", async () => { + const meta = { "io.modelcontextprotocol/protocolVersion": "2025-11-25" }; + const expected = failure( + -32_601, + "Method not found: server/discover belongs to 2026-07-28", + 1, + ); + + expect(await respond(request("server/discover"), LEGACY)).toStrictEqual(expected); + expect(await respond(request("server/discover", { _meta: meta }))).toStrictEqual(expected); + }); + }); + + describe("tools/list", () => { + test("should list the tools in order, in the shape of the handshake revisions after initialize", async () => { + const expected = { jsonrpc: "2.0", id: 1, result: { tools: LISTED_TOOLS } }; + + expect(await respond(request("tools/list"), LEGACY)).toStrictEqual(expected); + expect(await respond(request("tools/list", {}), LEGACY)).toStrictEqual(expected); + }); + + test("should add the result type, the cache hints and the identity for a modern request, session or not", async () => { + const message = request("tools/list", { _meta: MODERN_META }); + const expected = { + jsonrpc: "2.0", + id: 1, + result: { + resultType: "complete", + tools: LISTED_TOOLS, + ttlMs: 3_600_000, + cacheScope: "public", + _meta: SERVER_META, + }, + }; + + expect(await respond(message)).toStrictEqual(expected); + expect(await respond(message, LEGACY)).toStrictEqual(expected); + }); + + test("should refuse any cursor, since the list has a single page", async () => { + const messages = ["next", "", 0, null].map((cursor) => request("tools/list", { cursor })); + const expected = failure(-32_602, "Invalid cursor: the tool list has a single page", 1); + + expect(await respondAll(messages, LEGACY)).toStrictEqual(messages.map(() => expected)); + }); + }); + + describe("tools/call", () => { + test("should call the tool and answer in the shape of the era", async () => { + const params = { name: "shout", arguments: { value: "olá" } }; + const modern = request("tools/call", { ...params, _meta: MODERN_META }); + + expect(await respond(request("tools/call", params), LEGACY)).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: '"OLÁ"' }], isError: false }, + }); + expect(await respond(modern)).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: { + resultType: "complete", + content: [{ type: "text", text: '"OLÁ"' }], + isError: false, + _meta: SERVER_META, + }, + }); + }); + + test("should report a failing tool and bad arguments inside a successful response", async () => { + const failing = request("tools/call", { name: "lookup", arguments: { value: "x" } }); + const incomplete = request("tools/call", { name: "shout" }); + + expect(await respond(failing, LEGACY)).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "Error: offline: x" }], isError: true }, + }); + expect(await respond(incomplete, LEGACY)).toStrictEqual({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "arguments.value is required" }], isError: true }, + }); + }); + + test("should answer a name that is not a string with invalid params", async () => { + const messages = [undefined, 1, null, ["shout"]].map((name) => + request("tools/call", { name }), + ); + const expected = failure(-32_602, "params.name must be a string", 1); + + expect(await respondAll(messages, LEGACY)).toStrictEqual(messages.map(() => expected)); + }); + + test("should answer an unknown tool with invalid params", async () => { + const names = ["SHOUT", "", "toString", "__proto__"]; + const messages = names.map((name) => request("tools/call", { name })); + + expect(await respondAll(messages, LEGACY)).toStrictEqual( + names.map((name) => failure(-32_602, `Unknown tool: ${name}`, 1)), + ); + }); + + test("should refuse arguments that is not an object", async () => { + const messages = [[], "value", 1, null].map((args) => + request("tools/call", { name: "shout", arguments: args }), + ); + const expected = failure(-32_602, "params.arguments must be an object", 1); + + expect(await respondAll(messages, LEGACY)).toStrictEqual(messages.map(() => expected)); + }); + + test("should answer a tool the library does not implement with an internal error", async () => { + const message = request("tools/call", { name: "missing", arguments: { value: "" } }); + + expect(await respond(message, LEGACY)).toStrictEqual( + failure(-32_603, "TypeError: The library has no function named missing", 1), + ); + }); + }); + + describe("properties", () => { + test("should never throw nor reject, and answer only with JSON-RPC 2.0 responses", async () => { + await fc.assert( + fc.asyncProperty(anyMessage, fc.constantFrom(FRESH, LEGACY), async (message, session) => { + const response = await respond(message, session); + + if (response !== null) { + expect(response.jsonrpc).toBe("2.0"); + expect(Object.hasOwn(response, "result")).toBe(!Object.hasOwn(response, "error")); + expect(typeof JSON.stringify(response)).toBe("string"); + } + }), + ); + }); + + test("should echo the id of every request it answers", async () => { + await fc.assert( + fc.asyncProperty(anyRequestId, async (id) => { + const responses = await respondAll([ + request("ping", undefined, id), + request("nope", undefined, id), + ]); + + expect(responses.map((response) => response?.id)).toStrictEqual([id, id]); + }), + ); + }); + }); +}); + +describe("handleMessage types", () => { + test("should take a message, a session and a context and return the next session with the response", () => { + expectTypeOf(handleMessage).parameter(0).toEqualTypeOf(); + expectTypeOf(handleMessage).parameter(1).toEqualTypeOf(); + expectTypeOf(handleMessage).parameter(2).toEqualTypeOf(); + expectTypeOf(handleMessage).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Promise + >(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/_mcp/handle-message/handle-message.ts b/src/_mcp/handle-message/handle-message.ts new file mode 100644 index 00000000..8f850fdc --- /dev/null +++ b/src/_mcp/handle-message/handle-message.ts @@ -0,0 +1,341 @@ +import { callTool, type McpLibrary } from "../call-tool/call-tool"; +import { + CACHE_TTL_MS, + ERROR_CODES, + LEGACY_PROTOCOL_VERSIONS, + type McpTool, + META_CLIENT_CAPABILITIES, + META_PROTOCOL_VERSION, + META_SERVER_INFO, + MODERN_PROTOCOL_VERSION, + SERVER_INSTRUCTIONS, + SUPPORTED_PROTOCOL_VERSIONS, +} from "../constants"; + +/** The `Implementation` object the server identifies itself with. */ +type McpServerInfo = { + /** The programmatic name of the server. */ + name: string; + /** The version of the package the server ships in. */ + version: string; +}; + +/** What the server serves, injected so the handler stays a pure function. */ +export type McpContext = { + /** The identity reported by `initialize`, `server/discover` and every modern result. */ + serverInfo: McpServerInfo; + /** The tools `tools/list` answers with, in order. */ + tools: readonly McpTool[]; + /** The library the tools call into. */ + library: McpLibrary; +}; + +/** The state of one stdio connection. A modern, per request `_meta` exchange never reads it. */ +export type McpSession = { + /** The legacy revision agreed by `initialize`, or `null` before the handshake. */ + protocolVersion: string | null; +}; + +/** A JSON-RPC 2.0 response. `id` is absent when the request id could not be read. */ +export type JsonRpcResponse = { + /** The JSON-RPC version, always `"2.0"`. */ + jsonrpc: "2.0"; + /** The id of the request being answered. */ + id?: string | number; + /** The result of a successful request. */ + result?: Record; + /** The error of a failed request. */ + error?: { + /** The JSON-RPC or MCP error code. */ + code: number; + /** A short description of the error. */ + message: string; + /** Additional information about the error. */ + data?: unknown; + }; +}; + +/** What `handleMessage` answers with. */ +export type HandleMessageResult = { + /** The session after the message, a new object when `initialize` changed it. */ + session: McpSession; + /** The response to write, or `null` when the message gets none (a notification, a response). */ + response: Promise; +}; + +type RequestId = string | number; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isRequestId = (value: unknown): value is RequestId => + typeof value === "string" || Number.isInteger(value); + +const toError = ( + id: RequestId | undefined, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse => ({ + jsonrpc: "2.0", + ...(id === undefined ? {} : { id }), + error: { code, message, ...(data === undefined ? {} : { data }) }, +}); + +const toResult = (id: RequestId, result: Record): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + result, +}); + +const resolveEra = ( + id: RequestId, + params: Record, + session: McpSession, +): boolean | JsonRpcResponse => { + const meta = isRecord(params["_meta"]) ? params["_meta"] : {}; + + if (!Object.hasOwn(meta, META_PROTOCOL_VERSION)) { + if (session.protocolVersion !== null) return false; + + return toError( + id, + ERROR_CODES.invalidParams, + `Missing _meta["${META_PROTOCOL_VERSION}"]: send it with every request (${MODERN_PROTOCOL_VERSION}), or open with initialize (${LEGACY_PROTOCOL_VERSIONS[0]} and earlier)`, + ); + } + + const requested = meta[META_PROTOCOL_VERSION]; + const version = SUPPORTED_PROTOCOL_VERSIONS.find((supported) => supported === requested); + if (version === undefined) { + return toError(id, ERROR_CODES.unsupportedProtocolVersion, "Unsupported protocol version", { + supported: SUPPORTED_PROTOCOL_VERSIONS, + requested: String(requested), + }); + } + + if (version !== MODERN_PROTOCOL_VERSION) return false; + if (isRecord(meta[META_CLIENT_CAPABILITIES])) return true; + + return toError( + id, + ERROR_CODES.invalidParams, + `Missing _meta["${META_CLIENT_CAPABILITIES}"]: it must be an object, empty when the client has no optional capability`, + ); +}; + +const complete = ( + result: Record, + modern: boolean, + context: McpContext, +): Record => + modern + ? { resultType: "complete", ...result, _meta: { [META_SERVER_INFO]: context.serverInfo } } + : result; + +const cacheable = ( + result: Record, + modern: boolean, + context: McpContext, +): Record => + complete( + modern ? { ...result, ttlMs: CACHE_TTL_MS, cacheScope: "public" } : result, + modern, + context, + ); + +const initialize = ( + id: RequestId, + params: Record, + context: McpContext, +): { session: McpSession; response: JsonRpcResponse } | { response: JsonRpcResponse } => { + const requested = params["protocolVersion"]; + if (typeof requested !== "string") { + return { + response: toError(id, ERROR_CODES.invalidParams, "params.protocolVersion must be a string"), + }; + } + + const protocolVersion = LEGACY_PROTOCOL_VERSIONS.includes(requested) + ? requested + : LEGACY_PROTOCOL_VERSIONS[0]; + + return { + session: { protocolVersion }, + response: toResult(id, { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: context.serverInfo, + instructions: SERVER_INSTRUCTIONS, + }), + }; +}; + +const listTools = ( + id: RequestId, + params: Record, + modern: boolean, + context: McpContext, +): JsonRpcResponse => { + if (params["cursor"] !== undefined) { + return toError( + id, + ERROR_CODES.invalidParams, + "Invalid cursor: the tool list has a single page", + ); + } + + const tools = context.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: { readOnlyHint: true, openWorldHint: tool.network === true }, + })); + + return toResult(id, cacheable({ tools }, modern, context)); +}; + +const callRequestedTool = async ( + id: RequestId, + params: Record, + modern: boolean, + context: McpContext, +): Promise => { + const { name, arguments: args = {} } = params; + if (typeof name !== "string") { + return toError(id, ERROR_CODES.invalidParams, "params.name must be a string"); + } + + const tool = context.tools.find((candidate) => candidate.name === name); + if (tool === undefined) return toError(id, ERROR_CODES.invalidParams, `Unknown tool: ${name}`); + if (!isRecord(args)) { + return toError(id, ERROR_CODES.invalidParams, "params.arguments must be an object"); + } + + try { + const result = await callTool({ tool, args, library: context.library }); + return toResult(id, complete(result, modern, context)); + } catch (error) { + return toError(id, ERROR_CODES.internalError, String(error)); + } +}; + +/** + * Handles one decoded JSON-RPC 2.0 message of the Model Context Protocol and never throws. + * + * The server is dual-era, as the 2026-07-28 revision calls it. A request that carries + * `_meta["io.modelcontextprotocol/protocolVersion"]` is served statelessly under that revision: + * `server/discover`, `tools/list` and `tools/call`, with `resultType`, the cache hints and the + * server identity in the result, `-32022` for a revision the server does not speak and `-32602` + * when the client capabilities are missing. An `initialize` request opens a session under the + * handshake revisions (2025-11-25 down to 2024-11-05): the requested revision is echoed when the + * server speaks it and the newest handshake revision is offered otherwise, after which `ping`, + * `tools/list` and `tools/call` are served in the shape of those revisions. A request that does + * neither gets `-32602` with both ways forward in the message. + * + * Notifications (`notifications/initialized`, `notifications/cancelled`, anything without an + * `id`) and stray responses get no answer. A message that is not a request object gets `-32600`, + * an unknown method `-32601`, `params` that is not an object, an unknown tool and a cursor get + * `-32602`. A tool that fails answers with `isError: true` inside a successful response. + * + * @param {unknown} message - The decoded JSON value of one line of the transport. + * @param {McpSession} session - The session of the connection the message arrived on. + * @param {McpContext} context - The server identity, the tools and the library. + * @returns {HandleMessageResult} The next session and the response, `null` when there is none. + * + * @example + * ```typescript + * const { response } = handleMessage( + * { jsonrpc: "2.0", id: 1, method: "ping" }, + * { protocolVersion: null }, + * context, + * ); + * await response; // { jsonrpc: "2.0", id: 1, result: {} } + * ``` + * + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/basic + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/server/discover + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/server/tools + * @see Official: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + * @see Official: https://www.jsonrpc.org/specification + */ +export const handleMessage = ( + message: unknown, + session: McpSession, + context: McpContext, +): HandleMessageResult => { + const answer = (response: JsonRpcResponse | null): HandleMessageResult => ({ + session, + response: Promise.resolve(response), + }); + + if (!isRecord(message)) { + return answer(toError(undefined, ERROR_CODES.invalidRequest, "The message must be an object")); + } + + const { id, method, params = {} } = message; + const requestId = isRequestId(id) ? id : undefined; + + if (message["jsonrpc"] !== "2.0" || typeof method !== "string") { + if (Object.hasOwn(message, "result") || Object.hasOwn(message, "error")) return answer(null); + + return answer( + toError(requestId, ERROR_CODES.invalidRequest, 'Expected jsonrpc "2.0" and a string method'), + ); + } + + if (!Object.hasOwn(message, "id")) return answer(null); + if (requestId === undefined) { + return answer( + toError(undefined, ERROR_CODES.invalidRequest, "The id must be a string or an integer"), + ); + } + + if (!isRecord(params)) { + return answer(toError(requestId, ERROR_CODES.invalidParams, "params must be an object")); + } + + if (method === "initialize") { + const initialized = initialize(requestId, params, context); + return { session, ...initialized, response: Promise.resolve(initialized.response) }; + } + + if (method === "ping") return answer(toResult(requestId, {})); + if (method !== "server/discover" && method !== "tools/list" && method !== "tools/call") { + return answer(toError(requestId, ERROR_CODES.methodNotFound, `Method not found: ${method}`)); + } + + const modern = resolveEra(requestId, params, session); + if (typeof modern !== "boolean") return answer(modern); + + if (method === "tools/call") { + return { session, response: callRequestedTool(requestId, params, modern, context) }; + } + + if (method === "tools/list") return answer(listTools(requestId, params, modern, context)); + if (!modern) { + return answer( + toError( + requestId, + ERROR_CODES.methodNotFound, + `Method not found: ${method} belongs to ${MODERN_PROTOCOL_VERSION}`, + ), + ); + } + + return answer( + toResult( + requestId, + cacheable( + { + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + capabilities: { tools: {} }, + instructions: SERVER_INSTRUCTIONS, + }, + true, + context, + ), + ), + ); +}; diff --git a/src/_mcp/parse-tool-arguments/parse-tool-arguments.test.ts b/src/_mcp/parse-tool-arguments/parse-tool-arguments.test.ts new file mode 100644 index 00000000..fab13079 --- /dev/null +++ b/src/_mcp/parse-tool-arguments/parse-tool-arguments.test.ts @@ -0,0 +1,327 @@ +import * as fc from "fast-check"; + +import { anyValue } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type McpJsonSchema } from "../constants"; +import { parseToolArguments, type ParseToolArgumentsResult } from "./parse-tool-arguments"; + +const VALUE_INPUT: McpJsonSchema = { + type: "object", + properties: { + value: { type: "string" }, + options: { + type: "object", + properties: { pad: { type: "boolean" } }, + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, +}; + +describe("parseToolArguments", () => { + describe("types", () => { + const accepted = [ + ["string", "abc"], + ["string", ""], + ["number", 1.5], + ["number", 0], + ["integer", -3], + ["boolean", false], + ["array", []], + ["object", {}], + ] as const; + + for (const [type, value] of accepted) { + test(`should accept a ${type} such as ${JSON.stringify(value)}`, () => { + expect(parseToolArguments({ type }, value, "arguments.value")).toStrictEqual({ + ok: true, + value, + }); + }); + } + + const refused = [ + ["string", 1], + ["string", null], + ["number", "1"], + ["number", Number.NaN], + ["number", Number.POSITIVE_INFINITY], + ["integer", 1.5], + ["integer", "1"], + ["boolean", 0], + ["boolean", "true"], + ["array", {}], + ["array", "a"], + ["object", []], + ["object", null], + ["object", "a"], + ] as const; + + for (const [index, [type, value]] of refused.entries()) { + test(`should refuse a ${type} given as ${JSON.stringify(value)} (case ${index})`, () => { + expect(parseToolArguments({ type }, value, "arguments.value")).toStrictEqual({ + ok: false, + message: `arguments.value must be of type ${type}`, + }); + }); + } + }); + + describe("enum", () => { + test("should accept a listed string and a listed number", () => { + expect(parseToolArguments({ type: "string", enum: ["SP", "RJ"] }, "RJ", "a")).toStrictEqual({ + ok: true, + value: "RJ", + }); + expect(parseToolArguments({ type: "integer", enum: [1, 2] }, 2, "a")).toStrictEqual({ + ok: true, + value: 2, + }); + }); + + test("should refuse a value that is not listed and name the listed ones", () => { + expect( + parseToolArguments({ type: "string", enum: ["SP", "RJ"] }, "sp", "arguments.stateCode"), + ).toStrictEqual({ ok: false, message: 'arguments.stateCode must be one of "SP", "RJ"' }); + expect( + parseToolArguments({ type: "integer", enum: [1, 2] }, 3, "arguments.version"), + ).toStrictEqual({ ok: false, message: "arguments.version must be one of 1, 2" }); + }); + }); + + describe("objects", () => { + test("should copy the listed properties, nested ones included", () => { + const value = { value: "123", options: { pad: true } }; + const parsed = parseToolArguments(VALUE_INPUT, value, "arguments"); + + expect(parsed).toStrictEqual({ ok: true, value: { value: "123", options: { pad: true } } }); + expect(parsed.ok && parsed.value).not.toBe(value); + }); + + test("should accept an object without its optional properties", () => { + expect(parseToolArguments(VALUE_INPUT, { value: "" }, "arguments")).toStrictEqual({ + ok: true, + value: { value: "" }, + }); + }); + + test("should accept an empty object under a schema without properties or required", () => { + expect(parseToolArguments({ type: "object" }, {}, "arguments")).toStrictEqual({ + ok: true, + value: {}, + }); + }); + + test("should refuse a missing required property", () => { + expect(parseToolArguments(VALUE_INPUT, {}, "arguments")).toStrictEqual({ + ok: false, + message: "arguments.value is required", + }); + }); + + test("should refuse a required property that is only inherited", () => { + const inherited: Record = Object.create({ value: "123" }); + + expect(parseToolArguments(VALUE_INPUT, inherited, "arguments")).toStrictEqual({ + ok: false, + message: "arguments.value is required", + }); + }); + + test("should refuse a property the schema does not list and name the listed ones", () => { + expect(parseToolArguments(VALUE_INPUT, { value: "1", cpf: "2" }, "arguments")).toStrictEqual({ + ok: false, + message: "arguments.cpf is not accepted; known properties: value, options", + }); + expect(parseToolArguments({ type: "object" }, { a: 1 }, "arguments")).toStrictEqual({ + ok: false, + message: "arguments.a is not accepted; known properties: ", + }); + }); + + test("should report a nested problem with its full path", () => { + expect( + parseToolArguments(VALUE_INPUT, { value: "1", options: { pad: "yes" } }, "arguments"), + ).toStrictEqual({ ok: false, message: "arguments.options.pad must be of type boolean" }); + }); + + for (const key of ["__proto__", "constructor", "toString", "hasOwnProperty"]) { + test(`should refuse the prototype key ${key} instead of reading it off the schema`, () => { + const hostile: unknown = JSON.parse(`{"value":"1","${key}":{"polluted":true}}`); + + expect(parseToolArguments(VALUE_INPUT, hostile, "arguments")).toStrictEqual({ + ok: false, + message: `arguments.${key} is not accepted; known properties: value, options`, + }); + expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); + }); + } + }); + + describe("arrays", () => { + const schema: McpJsonSchema = { + type: "array", + items: { type: "string", enum: ["mobile", "landline"] }, + }; + + test("should copy the items that satisfy the item schema", () => { + const value = ["mobile", "landline"]; + const parsed = parseToolArguments(schema, value, "arguments.accept"); + + expect(parsed).toStrictEqual({ ok: true, value: ["mobile", "landline"] }); + expect(parsed.ok && parsed.value).not.toBe(value); + expect(parseToolArguments(schema, [], "arguments.accept")).toStrictEqual({ + ok: true, + value: [], + }); + }); + + test("should report the index of the first item that breaks the item schema", () => { + expect(parseToolArguments(schema, ["mobile", 7, 8], "arguments.accept")).toStrictEqual({ + ok: false, + message: "arguments.accept[1] must be of type string", + }); + expect(parseToolArguments(schema, ["fax"], "arguments.accept")).toStrictEqual({ + ok: false, + message: 'arguments.accept[0] must be one of "mobile", "landline"', + }); + }); + + test("should pass the items through when the schema does not describe them", () => { + const value = [1, "a", null]; + const parsed = parseToolArguments({ type: "array" }, value, "arguments.list"); + + expect(parsed).toStrictEqual({ ok: true, value: [1, "a", null] }); + expect(parsed.ok && parsed.value).toBe(value); + }); + }); + + describe("dates", () => { + const schema: McpJsonSchema = { type: "string", format: "date" }; + + test("should turn a calendar date into a Date at local midnight", () => { + const parsed = parseToolArguments(schema, "2024-02-29", "arguments.date"); + + expect(parsed.ok).toBe(true); + expect(parsed.ok && parsed.value instanceof Date).toBe(true); + const date = parsed.ok && parsed.value instanceof Date ? parsed.value : new Date(0); + expect([ + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ]).toStrictEqual([2024, 1, 29, 0, 0, 0, 0]); + }); + + test("should turn the dates nested in an object into Date values", () => { + const parsed = parseToolArguments( + { type: "object", properties: { options: { type: "object", properties: { on: schema } } } }, + { options: { on: "1999-12-31" } }, + "arguments", + ); + const value: any = parsed.ok ? parsed.value : {}; + + expect(value.options.on instanceof Date).toBe(true); + expect(value.options.on.getFullYear()).toBe(1999); + expect(value.options.on.getMonth()).toBe(11); + expect(value.options.on.getDate()).toBe(31); + }); + + const notDates = [ + "2023-02-29", + "2024-13-01", + "2024-00-10", + "2024-01-00", + "2024-04-31", + "0050-01-01", + "2024-1-1", + "01/01/2024", + "2024-01-01T00:00:00Z", + " 2024-01-01", + "2024-01-01\n", + "x2024-01-01", + "2024-01-011", + "", + ]; + + for (const value of notDates) { + test(`should refuse ${JSON.stringify(value)}, which is not a YYYY-MM-DD calendar date`, () => { + expect(parseToolArguments(schema, value, "arguments.date")).toStrictEqual({ + ok: false, + message: "arguments.date must be a calendar date written as YYYY-MM-DD", + }); + }); + } + + test("should check the type before reading the date", () => { + expect(parseToolArguments(schema, 20_240_101, "arguments.date")).toStrictEqual({ + ok: false, + message: "arguments.date must be of type string", + }); + }); + }); + + describe("properties", () => { + test("should never throw and always answer with a verdict", () => { + fc.assert( + fc.property(anyValue, (value) => { + const parsed = parseToolArguments(VALUE_INPUT, value, "arguments"); + + expect(typeof parsed.ok).toBe("boolean"); + }), + ); + }); + + test("should accept every string under a string schema and return it untouched", () => { + fc.assert( + fc.property(fc.string(), (value) => { + expect(parseToolArguments({ type: "string" }, value, "a")).toStrictEqual({ + ok: true, + value, + }); + }), + ); + }); + + test("should round trip every calendar date", () => { + fc.assert( + fc.property( + fc.integer({ min: 1900, max: 2099 }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1, max: 28 }), + (year, month, day) => { + const text = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const parsed = parseToolArguments({ type: "string", format: "date" }, text, "a"); + const date = parsed.ok && parsed.value instanceof Date ? parsed.value : new Date(0); + + expect([date.getFullYear(), date.getMonth() + 1, date.getDate()]).toStrictEqual([ + year, + month, + day, + ]); + }, + ), + ); + }); + }); +}); + +describe("parseToolArguments types", () => { + test("should take a schema, a value and a path and return a verdict", () => { + expectTypeOf(parseToolArguments).parameter(0).toEqualTypeOf(); + expectTypeOf(parseToolArguments).parameter(2).toEqualTypeOf(); + expectTypeOf( + parseToolArguments({ type: "string" }, "a" as unknown, "a"), + ).toEqualTypeOf(); + }); + + test("should keep the object shape of an arguments object", () => { + expectTypeOf(parseToolArguments({ type: "object" }, {}, "arguments")).toEqualTypeOf< + ParseToolArgumentsResult> + >(); + }); +}); diff --git a/src/_mcp/parse-tool-arguments/parse-tool-arguments.ts b/src/_mcp/parse-tool-arguments/parse-tool-arguments.ts new file mode 100644 index 00000000..89d924d5 --- /dev/null +++ b/src/_mcp/parse-tool-arguments/parse-tool-arguments.ts @@ -0,0 +1,139 @@ +import { type McpJsonSchema, type McpJsonSchemaType } from "../constants"; + +/** Outcome of `parseToolArguments`: the value ready for the library, or why it was refused. */ +export type ParseToolArgumentsResult = + | { + /** The value satisfies the schema. */ + ok: true; + /** A copy of the value with every `format: "date"` string turned into a local `Date`. */ + value: Value; + } + | { + /** The value breaks the schema. */ + ok: false; + /** What is wrong and where, written for the model that has to fix the call. */ + message: string; + }; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const TYPE_CHECKS: Record boolean> = { + array: (value) => Array.isArray(value), + boolean: (value) => typeof value === "boolean", + integer: (value) => Number.isInteger(value), + number: (value) => Number.isFinite(value), + object: isPlainObject, + string: (value) => typeof value === "string", +}; + +const parseDate = (value: string, path: string): ParseToolArgumentsResult => { + const [year, month, day] = value.split("-").map(Number); + const date = new Date(year, month - 1, day); + + if (!DATE_PATTERN.test(value) || date.getFullYear() !== year || date.getDate() !== day) { + return { ok: false, message: `${path} must be a calendar date written as YYYY-MM-DD` }; + } + + return { ok: true, value: date }; +}; + +type ParseValue = (schema: McpJsonSchema, value: unknown, path: string) => ParseToolArgumentsResult; + +const parseObject = ( + schema: McpJsonSchema, + value: Record, + path: string, + parseValue: ParseValue, +): ParseToolArgumentsResult => { + const properties = schema.properties ?? {}; + const missing = (schema.required ?? []).find((key) => !Object.hasOwn(value, key)); + if (missing !== undefined) return { ok: false, message: `${path}.${missing} is required` }; + + const entries: [string, unknown][] = []; + for (const [key, item] of Object.entries(value)) { + if (!Object.hasOwn(properties, key)) { + const known = Object.keys(properties).join(", "); + return { ok: false, message: `${path}.${key} is not accepted; known properties: ${known}` }; + } + + const parsed = parseValue(properties[key], item, `${path}.${key}`); + if (!parsed.ok) return parsed; + entries.push([key, parsed.value]); + } + + return { ok: true, value: Object.fromEntries(entries) }; +}; + +const parseArray = ( + schema: McpJsonSchema, + value: unknown[], + path: string, + parseValue: ParseValue, +): ParseToolArgumentsResult => { + const items: unknown[] = []; + for (const [index, item] of value.entries()) { + const parsed = parseValue(schema, item, `${path}[${index}]`); + if (!parsed.ok) return parsed; + items.push(parsed.value); + } + + return { ok: true, value: items }; +}; + +/** + * Checks a `tools/call` value against the JSON Schema subset the tool table is written in + * (`type`, `enum`, `properties`, `required`, `items` and `format: "date"`) and prepares it for the + * library. Objects are closed: a property the schema does not list is refused, which is what + * `additionalProperties: false` tells the client, and the copy is built from own properties only, + * so a `__proto__` key in the JSON never reaches a prototype. A `format: "date"` string becomes a + * `Date` at local midnight, the calendar convention the date utilities read. + * + * @param {McpJsonSchema} schema - The schema the value has to satisfy. + * @param {unknown} value - The value received from the client. + * @param {string} path - Where the value sits, used as the prefix of the error message. + * @returns {ParseToolArgumentsResult} The prepared value, or the first problem found. + * + * @example + * ```typescript + * parseToolArguments({ type: "string" }, "abc", "arguments.value"); // { ok: true, value: "abc" } + * parseToolArguments({ type: "string" }, 1, "arguments.value"); + * // { ok: false, message: "arguments.value must be of type string" } + * ``` + * + * @see Official: https://json-schema.org/draft/2020-12/json-schema-validation + */ +export function parseToolArguments( + schema: McpJsonSchema, + value: Record, + path: string, +): ParseToolArgumentsResult>; +export function parseToolArguments( + schema: McpJsonSchema, + value: unknown, + path: string, +): ParseToolArgumentsResult; +export function parseToolArguments( + schema: McpJsonSchema, + value: unknown, + path: string, +): ParseToolArgumentsResult { + if (!TYPE_CHECKS[schema.type](value)) { + return { ok: false, message: `${path} must be of type ${schema.type}` }; + } + + if (schema.enum !== undefined && !schema.enum.some((item) => item === value)) { + const allowed = schema.enum.map((item) => JSON.stringify(item)).join(", "); + return { ok: false, message: `${path} must be one of ${allowed}` }; + } + + if (schema.format === "date") return parseDate(String(value), path); + if (isPlainObject(value)) return parseObject(schema, value, path, parseToolArguments); + if (Array.isArray(value) && schema.items !== undefined) { + return parseArray(schema.items, value, path, parseToolArguments); + } + + return { ok: true, value }; +} diff --git a/src/_mcp/serve-stdio/serve-stdio.test.ts b/src/_mcp/serve-stdio/serve-stdio.test.ts new file mode 100644 index 00000000..56716684 --- /dev/null +++ b/src/_mcp/serve-stdio/serve-stdio.test.ts @@ -0,0 +1,370 @@ +import * as fc from "fast-check"; + +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type McpTool } from "../constants"; +import { type McpContext } from "../handle-message/handle-message"; +import { + serveStdio, + type ServeStdioParams, + type StdioInput, + type StdioOutput, +} from "./serve-stdio"; + +type Listener = (chunk?: Uint8Array) => unknown; + +type Harness = { + output: string[]; + log: string[]; + write: (text: string) => Promise; + writeBytes: (bytes: Uint8Array) => Promise; + end: () => Promise; + release: (value: string) => void; +}; + +const TOOLS: McpTool[] = ["shout", "slow"].map((name) => ({ + name, + description: `The ${name} tool of the tests.`, + parameters: ["value"], + inputSchema: { type: "object", properties: { value: { type: "string" } } }, +})); + +const INITIALIZE = + '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'; + +const callLine = (id: number | string, name: string, value: string): string => + JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: { value } }, + }); + +const resultLine = (id: number | string, text: string): string => + `${JSON.stringify({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], isError: false } })}\n`; + +const flush = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +const writeAll = async ( + write: (text: string) => Promise, + [first, ...rest]: string[], +): Promise => { + if (first === undefined) return; + + await write(first); + await writeAll(write, rest); +}; + +const anyChunks = fc.array(fc.string(), { maxLength: 5 }); + +const start = (failingOutput = false): Harness => { + const listeners: Record = {}; + const output: string[] = []; + const log: string[] = []; + const releases: ((value: string) => void)[] = []; + const released = new Promise((resolve) => { + releases.push(resolve); + }); + + const context: McpContext = { + serverInfo: { name: "brazilian-utils", version: "9.9.9" }, + tools: TOOLS, + library: { + shout: (value: string): string => value.toUpperCase(), + slow: (): Promise => released, + }, + }; + + serveStdio({ + input: { + on: (event, listener) => { + listeners[event] = listener; + }, + }, + output: { + write: (text) => { + if (failingOutput) throw new Error("EPIPE"); + output.push(text); + }, + }, + log: { write: (text) => log.push(text) }, + context, + }); + + const writeBytes = async (bytes: Uint8Array): Promise => { + await Promise.race([listeners["data"](bytes), flush()]); + await flush(); + }; + + return { + output, + log, + writeBytes, + write: (text) => writeBytes(new TextEncoder().encode(text)), + end: async () => { + await Promise.race([listeners["end"](), flush()]); + await flush(); + }, + release: (value) => { + for (const release of releases) release(value); + }, + }; +}; + +describe("serveStdio", () => { + test("should announce itself on the log and write nothing to the output", () => { + const { output, log } = start(); + + expect(log).toStrictEqual(["brazilian-utils-mcp 9.9.9: serving 2 tools on stdio\n"]); + expect(output).toStrictEqual([]); + }); + + test("should answer each line with one line of JSON", async () => { + const { output, write } = start(); + + await write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n'); + await write( + '{"jsonrpc":"2.0","id":"b","method":"ping"}\n{"jsonrpc":"2.0","id":3,"method":"nope"}\n', + ); + + expect(output).toStrictEqual([ + '{"jsonrpc":"2.0","id":1,"result":{}}\n', + '{"jsonrpc":"2.0","id":"b","result":{}}\n', + '{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"Method not found: nope"}}\n', + ]); + }); + + test("should keep the session the handshake opens for the lines that follow", async () => { + const { output, write } = start(); + + await write(`${callLine(1, "shout", "a")}\n`); + await write(`${INITIALIZE}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n`); + await write(`${callLine(2, "shout", "olá")}\n`); + + expect(output).toHaveLength(3); + expect(JSON.parse(output[0]).error.code).toBe(-32_602); + expect(JSON.parse(output[1]).result.protocolVersion).toBe("2025-06-18"); + expect(output[2]).toBe(resultLine(2, '"OLÁ"')); + }); + + test("should open the session before a request of the same chunk is handled", async () => { + const { output, write } = start(); + + await write(`${INITIALIZE}\n${callLine(2, "shout", "a")}\n`); + + expect(output).toHaveLength(2); + expect(output[1]).toBe(resultLine(2, '"A"')); + }); + + test("should wait for the newline, wherever the chunks break", async () => { + const { output, write } = start(); + + await write('{"jsonrpc":"2.0",'); + await write('"id":1,"meth'); + + expect(output).toStrictEqual([]); + + await write('od":"ping"}\n{"jsonrpc"'); + + expect(output).toStrictEqual(['{"jsonrpc":"2.0","id":1,"result":{}}\n']); + + await write(':"2.0","id":2,"method":"ping"}\r\n'); + + expect(output).toStrictEqual([ + '{"jsonrpc":"2.0","id":1,"result":{}}\n', + '{"jsonrpc":"2.0","id":2,"result":{}}\n', + ]); + }); + + test("should decode a multi-byte character split between two chunks", async () => { + const { output, write, writeBytes } = start(); + const bytes = new TextEncoder().encode(`${callLine(1, "shout", "ação")}\n`); + const middle = bytes.indexOf(0xc3) + 1; + + await write(`${INITIALIZE}\n`); + await writeBytes(bytes.slice(0, middle)); + await writeBytes(bytes.slice(middle)); + + expect(output[1]).toBe(resultLine(1, '"AÇÃO"')); + }); + + test("should skip blank lines", async () => { + const { output, log, write } = start(); + + await write('\n\n \n\r\n{"jsonrpc":"2.0","id":1,"method":"ping"}\n\n'); + + expect(output).toStrictEqual(['{"jsonrpc":"2.0","id":1,"result":{}}\n']); + expect(log).toHaveLength(1); + }); + + test("should read a last line that has no newline when the input ends, and only once", async () => { + const { output, write, end } = start(); + + await write('{"jsonrpc":"2.0","id":1,"method":"ping"}'); + + expect(output).toStrictEqual([]); + + await end(); + await end(); + + expect(output).toStrictEqual(['{"jsonrpc":"2.0","id":1,"result":{}}\n']); + }); + + test("should write nothing when the input ends on a complete line", async () => { + const { output, log, write, end } = start(); + + await write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n'); + await end(); + + expect(output).toHaveLength(1); + expect(log).toHaveLength(1); + }); + + test("should answer a line that is not JSON with a parse error, log it and go on", async () => { + const { output, log, write } = start(); + + await write('{"jsonrpc":\nnot json\n{"jsonrpc":"2.0","id":1,"method":"ping"}\n'); + + expect(output).toStrictEqual([ + '{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}\n', + '{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}\n', + '{"jsonrpc":"2.0","id":1,"result":{}}\n', + ]); + expect(log.slice(1)).toStrictEqual([ + "brazilian-utils-mcp: discarded a line that is not JSON\n", + "brazilian-utils-mcp: discarded a line that is not JSON\n", + ]); + }); + + test("should answer JSON that is not a request object without crashing", async () => { + const { output, write } = start(); + + await write('null\n42\n"ping"\n[]\n{"jsonrpc":"2.0","id":1,"method":"ping"}\n'); + + expect(output).toHaveLength(5); + expect(output[0]).toBe( + '{"jsonrpc":"2.0","error":{"code":-32600,"message":"The message must be an object"}}\n', + ); + expect(output[4]).toBe('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + }); + + test("should never write a notification answer, nor a newline inside a message", async () => { + const { output, write } = start(); + + await write(`${INITIALIZE}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n`); + await write(`${callLine(1, "shout", "two\nlines")}\n`); + + expect(output).toHaveLength(2); + expect(output[1]).toBe(resultLine(1, String.raw`"TWO\nLINES"`)); + expect(output[1].indexOf("\n")).toBe(output[1].length - 1); + }); + + test("should answer a fast request while a slow one is pending", async () => { + const { output, write, release } = start(); + + await write(`${INITIALIZE}\n${callLine(1, "slow", "a")}\n${callLine(2, "shout", "b")}\n`); + + expect(output.slice(1)).toStrictEqual([resultLine(2, '"B"')]); + + release("done"); + await flush(); + + expect(output.slice(1)).toStrictEqual([resultLine(2, '"B"'), resultLine(1, '"done"')]); + }); + + test("should drop the response to a request the client cancelled", async () => { + const { output, write, release } = start(); + + await write(`${INITIALIZE}\n${callLine(1, "slow", "a")}\n${callLine("1", "slow", "b")}\n`); + await write( + '{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"timeout"}}\n', + ); + release("done"); + await flush(); + + expect(output.slice(1)).toStrictEqual([resultLine("1", '"done"')]); + }); + + test("should ignore a cancellation that names no request in flight", async () => { + const { output, write, release } = start(); + + await write(`${INITIALIZE}\n${callLine(1, "slow", "a")}\n`); + await write('{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":9}}\n'); + await write('{"jsonrpc":"2.0","method":"notifications/cancelled"}\n'); + await write('{"jsonrpc":"2.0","method":"notifications/other","params":{"requestId":1}}\n'); + await write('{"jsonrpc":"2.0","id":5,"method":"ping","params":{"requestId":1}}\n'); + release("done"); + await flush(); + + expect(output.slice(1)).toStrictEqual([ + '{"jsonrpc":"2.0","id":5,"result":{}}\n', + resultLine(1, '"done"'), + ]); + }); + + test("should log a response that cannot be written and keep serving", async () => { + const { output, log, write } = start(true); + + await write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n{"jsonrpc":"2.0","method":"x"}\n'); + + expect(output).toStrictEqual([]); + expect(log.slice(1)).toStrictEqual([ + "brazilian-utils-mcp: could not answer a message: Error: EPIPE\n", + ]); + }); + + describe("properties", () => { + test("should never throw, and write only complete lines of JSON, whatever the input", async () => { + await fc.assert( + fc.asyncProperty(anyChunks, async (chunks) => { + const { output, write, end } = start(); + + await writeAll(write, chunks); + await end(); + + for (const line of output) { + expect(line.endsWith("\n")).toBe(true); + expect(line.indexOf("\n")).toBe(line.length - 1); + expect(JSON.parse(line).jsonrpc).toBe("2.0"); + } + }), + ); + }); + + test("should answer the same whatever way the bytes are split into chunks", async () => { + const text = `${INITIALIZE}\n${callLine(1, "shout", "ação")}\n{"jsonrpc":"2.0","id":2,"method":"ping"}\n`; + const bytes = new TextEncoder().encode(text); + + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: bytes.length - 1 }), async (cut) => { + const { output, writeBytes } = start(); + + await writeBytes(bytes.slice(0, cut)); + await writeBytes(bytes.slice(cut)); + + expect(output.slice(1).toSorted()).toStrictEqual([ + resultLine(1, '"AÇÃO"'), + '{"jsonrpc":"2.0","id":2,"result":{}}\n', + ]); + }), + ); + }); + }); +}); + +describe("serveStdio types", () => { + test("should take the streams and the context and return nothing", () => { + expectTypeOf(serveStdio).parameter(0).toEqualTypeOf(); + expectTypeOf(serveStdio).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("should accept the process streams of Node.js", () => { + expectTypeOf>().toExtend(); + expectTypeOf>().toExtend(); + }); +}); diff --git a/src/_mcp/serve-stdio/serve-stdio.ts b/src/_mcp/serve-stdio/serve-stdio.ts new file mode 100644 index 00000000..059bdc3c --- /dev/null +++ b/src/_mcp/serve-stdio/serve-stdio.ts @@ -0,0 +1,124 @@ +import { ERROR_CODES } from "../constants"; +import { + handleMessage, + type JsonRpcResponse, + type McpContext, + type McpSession, +} from "../handle-message/handle-message"; + +/** The part of a readable stream the transport uses, which `process.stdin` satisfies. */ +export type StdioInput = { + /** Subscribes to the chunks of the stream and to its end, which passes no chunk. */ + on: (event: "data" | "end", listener: (chunk?: Uint8Array) => unknown) => unknown; +}; + +/** The part of a writable stream the transport uses, which `process.stdout` and `process.stderr` satisfy. */ +export type StdioOutput = { + /** Writes one piece of text. */ + write: (text: string) => unknown; +}; + +/** What `serveStdio` needs: the three streams and what the server serves. */ +export type ServeStdioParams = { + /** Where the client writes its messages, one JSON value per line. */ + input: StdioInput; + /** Where the responses go, one JSON value per line and nothing else. */ + output: StdioOutput; + /** Where the diagnostics go. */ + log: StdioOutput; + /** The server identity, the tools and the library. */ + context: McpContext; +}; + +const read = (value: unknown, key: string): unknown => + typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; + +const getCancelledId = (message: unknown): unknown => + read(message, "method") === "notifications/cancelled" + ? read(read(message, "params"), "requestId") + : undefined; + +/** + * Serves the Model Context Protocol over the stdio transport: newline delimited JSON-RPC messages + * read from `input`, responses written to `output` one per line, diagnostics written to `log` and + * never to `output`. A line that is not JSON is answered with a `-32700` parse error, a blank line + * is skipped, a last line without its newline is read when the input ends, and a chunk boundary + * may fall anywhere, inside a multi-byte character included. + * Requests run concurrently, so a slow CEP lookup does not hold the next request back, and the + * response to a request the client cancelled with `notifications/cancelled` is dropped. The + * process is left to exit by itself once the input ended and the pending responses are written. + * + * @param {ServeStdioParams} params - The streams and the server context. + * @returns {void} Nothing; the listener registered on `input` does the work. + * + * @example + * ```typescript + * serveStdio({ input: process.stdin, output: process.stdout, log: process.stderr, context }); + * ``` + * + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio + * @see Official: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation + */ +export const serveStdio = ({ input, output, log, context }: ServeStdioParams): void => { + const decoder = new TextDecoder(); + const inFlight = new Set<{ id: unknown }>(); + let session: McpSession = { protocolVersion: null }; + let buffer = ""; + + const send = (response: JsonRpcResponse): void => { + output.write(`${JSON.stringify(response)}\n`); + }; + + const receive = async (line: string): Promise => { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + log.write(`brazilian-utils-mcp: discarded a line that is not JSON\n`); + send({ jsonrpc: "2.0", error: { code: ERROR_CODES.parseError, message: "Parse error" } }); + return; + } + + const cancelledId = getCancelledId(message); + for (const entry of inFlight) { + if (cancelledId !== undefined && entry.id === cancelledId) inFlight.delete(entry); + } + + const handled = handleMessage(message, session, context); + ({ session } = handled); + + const entry = { id: read(message, "id") }; + inFlight.add(entry); + const response = await handled.response; + if (inFlight.delete(entry) && response !== null) send(response); + }; + + const receiveAll = async (lines: string[]): Promise => { + const outcomes = await Promise.allSettled( + lines.filter((line) => line.trim() !== "").map((line) => receive(line)), + ); + + for (const outcome of outcomes) { + if (outcome.status === "rejected") { + log.write(`brazilian-utils-mcp: could not answer a message: ${String(outcome.reason)}\n`); + } + } + }; + + input.on("data", async (chunk) => { + const text = `${buffer}${decoder.decode(chunk, { stream: true })}`; + const complete = text.lastIndexOf("\n") + 1; + buffer = text.slice(complete); + await receiveAll(text.slice(0, complete).split("\n")); + }); + + input.on("end", async () => { + const lines = [buffer]; + buffer = ""; + await receiveAll(lines); + }); + + log.write( + `brazilian-utils-mcp ${context.serverInfo.version}: serving ${context.tools.length} tools on stdio\n`, + ); +}; diff --git a/src/_mcp/to-json-value/to-json-value.test.ts b/src/_mcp/to-json-value/to-json-value.test.ts new file mode 100644 index 00000000..fbdf9013 --- /dev/null +++ b/src/_mcp/to-json-value/to-json-value.test.ts @@ -0,0 +1,83 @@ +import * as fc from "fast-check"; + +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { toJsonValue } from "./to-json-value"; + +describe("toJsonValue", () => { + test("should write a Date as its local calendar date", () => { + expect(toJsonValue(new Date(2024, 0, 1))).toBe("2024-01-01"); + expect(toJsonValue(new Date(2024, 11, 31, 23, 59, 59, 999))).toBe("2024-12-31"); + expect(toJsonValue(new Date(1999, 9, 5, 12))).toBe("1999-10-05"); + }); + + test("should pad a year below 1000 to four digits", () => { + const date = new Date(2024, 2, 9); + date.setFullYear(987); + + expect(toJsonValue(date)).toBe("0987-03-09"); + }); + + test("should write an invalid Date and undefined as null", () => { + expect(toJsonValue(new Date("not a date"))).toBeNull(); + expect(toJsonValue()).toBeNull(); + }); + + test("should return primitives and null untouched", () => { + expect(toJsonValue("746.506.880-00")).toBe("746.506.880-00"); + expect(toJsonValue("")).toBe(""); + expect(toJsonValue(0)).toBe(0); + expect(toJsonValue(1234.56)).toBe(1234.56); + expect(toJsonValue(true)).toBe(true); + expect(toJsonValue(false)).toBe(false); + expect(toJsonValue(null)).toBeNull(); + }); + + test("should copy arrays and objects recursively", () => { + const holiday = { name: "Ano novo", date: new Date(2024, 0, 1), type: "national" }; + const holidays = [holiday]; + + expect(toJsonValue(holidays)).toStrictEqual([ + { name: "Ano novo", date: "2024-01-01", type: "national" }, + ]); + expect(toJsonValue(holidays)).not.toBe(holidays); + expect(holiday.date instanceof Date).toBe(true); + expect(toJsonValue({ info: { expirationDate: null, tags: ["a", undefined] } })).toStrictEqual({ + info: { expirationDate: null, tags: ["a", null] }, + }); + expect(toJsonValue([])).toStrictEqual([]); + expect(toJsonValue({})).toStrictEqual({}); + }); + + describe("properties", () => { + test("should leave every JSON value unchanged", () => { + fc.assert( + fc.property(fc.jsonValue(), (value) => { + expect(JSON.stringify(toJsonValue(value))).toBe(JSON.stringify(value)); + }), + ); + }); + + test("should write every local calendar date as YYYY-MM-DD", () => { + fc.assert( + fc.property( + fc.integer({ min: 1900, max: 2099 }), + fc.integer({ min: 0, max: 11 }), + fc.integer({ min: 1, max: 28 }), + fc.integer({ min: 0, max: 23 }), + (year, month, day, hour) => { + expect(toJsonValue(new Date(year, month, day, hour))).toBe( + `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + ); + }, + ), + ); + }); + }); +}); + +describe("toJsonValue types", () => { + test("should take any value and return an unknown one", () => { + expectTypeOf(toJsonValue).parameters.toEqualTypeOf<[value?: unknown]>(); + expectTypeOf(toJsonValue).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_mcp/to-json-value/to-json-value.ts b/src/_mcp/to-json-value/to-json-value.ts new file mode 100644 index 00000000..6ae0be47 --- /dev/null +++ b/src/_mcp/to-json-value/to-json-value.ts @@ -0,0 +1,37 @@ +import { isValidDate } from "../../_internals/is-valid-date/is-valid-date"; + +const toCalendarDate = (date: Date): string => + [ + String(date.getFullYear()).padStart(4, "0"), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); + +/** + * Prepares a library result for `JSON.stringify`. A `Date` becomes its local calendar date + * written as `YYYY-MM-DD`, the convention the date utilities build their results in, instead of + * the UTC instant `Date#toJSON` writes, which names the previous day east of Greenwich. An + * invalid `Date` and `undefined` become `null`; arrays and objects are copied recursively. + * + * @param {unknown} value - The value a library function returned. + * @returns {unknown} The JSON ready copy. + * + * @example + * ```typescript + * toJsonValue({ name: "Ano novo", date: new Date(2024, 0, 1) }); + * // { name: "Ano novo", date: "2024-01-01" } + * toJsonValue(undefined); // null + * ``` + */ +export const toJsonValue = (value?: unknown): unknown => { + if (value === undefined) return null; + if (value instanceof Date) return isValidDate(value) ? toCalendarDate(value) : null; + if (Array.isArray(value)) return value.map((item) => toJsonValue(item)); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]): [string, unknown] => [key, toJsonValue(item)]), + ); + } + + return value; +}; diff --git a/stryker.config.json b/stryker.config.json index 175bc004..12857416 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -12,7 +12,8 @@ "!src/**/constants.ts", "!src/_internals/constants/**", "!src/index.ts", - "!src/_cli/bin/bin.ts" + "!src/_cli/bin/bin.ts", + "!src/_mcp/brazilian-utils-mcp.ts" ], "coverageAnalysis": "perTest", "reporters": ["clear-text", "progress", "html", "json"], diff --git a/vite.config.ts b/vite.config.ts index 88427e71..16d81168 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,10 +56,11 @@ const minifyUmdChunk = (): PackPlugin => ({ }); /** - * The command line (`src/_cli/bin/bin.ts`, published as the `bin` of the package) dispatches to - * the whole public API. Bundling `src/index.ts` into it would ship every dataset a second time, - * so its import of the barrel is rewritten to the root ESM bundle that sits next to it in `dist/`. - * @returns {PackPlugin} The pack plugin that points the command line at `dist/brazilian-utils.js`. + * The command line (`src/_cli/bin/bin.ts`) and the MCP server (`src/_mcp/brazilian-utils-mcp.ts`), + * both published as a `bin` of the package, dispatch to the whole public API. Bundling + * `src/index.ts` into them would ship every dataset a second time, so their import of the barrel + * is rewritten to the root ESM bundle that sits next to them in `dist/`. + * @returns {PackPlugin} The pack plugin that points a `bin` at `dist/brazilian-utils.js`. */ const externalizeLibrary = (): PackPlugin => ({ name: "brazilian-utils:externalize-library", @@ -90,7 +91,8 @@ const utilEntries = Object.fromEntries( ); /** - * Settings shared by both pack configs below (the root build and the per-util subpath build). + * Settings shared by the pack configs below (the root build, the per-util subpath build and the + * command line and MCP server builds). */ const sharedPack = { outDir: "dist", @@ -112,7 +114,7 @@ const sharedPack = { moduleSideEffects: false, propertyReadSideEffects: false, }, - // Both pack configs below declare the same `publint`/`attw` value, so the build engine + // Every pack config below declares the same `publint`/`attw` value, so the build engine // dedupes them and runs a single combined check over the fully assembled `dist/`, once // every entry (root + every subpath) has finished building, rather than once per config. publint: true, @@ -575,6 +577,7 @@ export default defineConfig({ "src/_internals/constants/**", "src/index.ts", "src/_cli/bin/bin.ts", + "src/_mcp/brazilian-utils-mcp.ts", ], thresholds: { statements: 100, @@ -610,5 +613,17 @@ export default defineConfig({ format: ["es"], plugins: [externalizeLibrary()], }, + // The MCP server: one ESM file with a shebang, no declarations (it exports nothing) and the + // library left external (see `externalizeLibrary`), so it costs library consumers nothing: no + // entry point imports it, and `exports` maps its subpath to `null`, since importing it + // would start the server. + { + ...sharedPack, + sourcemap: false, + dts: false, + entry: { "brazilian-utils-mcp": resolve(rootDir, "src/_mcp/brazilian-utils-mcp.ts") }, + format: ["es"], + plugins: [externalizeLibrary()], + }, ], });