Add Jortt API MCP server

Model Context Protocol server that exposes the full Jortt accounting API
as MCP tools, generated from Jortt's OpenAPI specification.

- OAuth 2.0 client-credentials auth with token caching and 401 refresh
- 82 tools covering customers, invoices, estimates, expenses, projects,
  bank accounts, reports, labels, ledger accounts and payroll
- Spec fetched at build time (with a runtime fetch fallback), so new API
  operations become new tools automatically with no code changes
- Path/query/body parameters handled automatically; request bodies passed
  under a single `body` argument with a resolved JSON Schema
- Readable, stable snake_case tool names derived from method, path and
  operation summary

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QhaFvGz9gfRg5qDV3ragu
This commit is contained in:
Claude 2026-07-11 14:09:11 +00:00
commit 240a53fade
No known key found for this signature in database
12 changed files with 2057 additions and 0 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
# Copy to .env and fill in credentials from your registered Jortt API application:
# https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list
JORTT_CLIENT_ID=
JORTT_CLIENT_SECRET=
# Optional: space-separated scopes to request (defaults to all registered scopes)
# JORTT_SCOPES=invoices:read customers:read reports:read

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.env
.DS_Store
# Fetched at build time by scripts/fetch-spec.mjs
src/openapi.json

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 deprekated
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

148
README.md Normal file
View file

@ -0,0 +1,148 @@
# jortt-mcp
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for the
[Jortt](https://www.jortt.nl) accounting & invoicing API.
It exposes the full Jortt REST API — customers, invoices, estimates, expenses,
projects, bank accounts, reports, and more — as MCP tools, so an MCP-capable
assistant (Claude Desktop, Claude Code, etc.) can read and write your Jortt
administration on your behalf.
The tools are generated directly from Jortt's published
[OpenAPI specification](https://api.jortt.nl/swagger_doc), so coverage stays
faithful to the API: **82 tools** across every documented resource.
## How it works
- The build step downloads the Jortt OpenAPI spec to `src/openapi.json` and
bundles it into `dist/`. On startup the server reads that spec, resolves all
schema references, and turns every operation into an MCP tool with a proper
JSON Schema for its arguments. (If no bundled spec is found, it fetches one
live from Jortt as a fallback.)
- Authentication uses the OAuth 2.0 **client credentials** grant (the flow Jortt
recommends for applications bound to your own administration). The server
fetches a bearer token, caches it until shortly before it expires, and
transparently refreshes on expiry or a `401`.
- Path parameters, query parameters, and JSON request bodies are all handled
automatically. Request bodies are passed under a single `body` argument.
## Prerequisites
1. A [Jortt account](https://app.jortt.nl/van-start).
2. A registered API application. Create one under
**Profile → API**
[OAuth applications](https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list),
selecting the scopes you want to grant. Registration gives you a
**Client ID** and **Client secret**.
## Installation
```bash
git clone https://github.com/deprekated/jortt-mcp.git
cd jortt-mcp
npm install # runs the build, which downloads the current Jortt OpenAPI spec
```
> The build fetches `https://api.jortt.nl/swagger_doc`, so it needs outbound
> network access. Set `JORTT_OPENAPI_URL` to point at a local copy or mirror if
> you build offline.
## Configuration
The server is configured entirely through environment variables:
| Variable | Required | Description |
| -------------------- | -------- | --------------------------------------------------------------------------- |
| `JORTT_CLIENT_ID` | yes | OAuth client ID of your registered application. |
| `JORTT_CLIENT_SECRET`| yes | OAuth client secret. |
| `JORTT_SCOPES` | no | Space-separated scopes to request. Defaults to all scopes; the token server only grants scopes your application was registered with. |
| `JORTT_OPENAPI_URL` | no | Override the OpenAPI spec source (used at build time and as a runtime fallback). Defaults to `https://api.jortt.nl/swagger_doc`. |
### Claude Desktop / Claude Code
Add the server to your MCP client configuration (for Claude Desktop, this is
`claude_desktop_config.json`):
```json
{
"mcpServers": {
"jortt": {
"command": "node",
"args": ["/absolute/path/to/jortt-mcp/dist/index.js"],
"env": {
"JORTT_CLIENT_ID": "your-client-id",
"JORTT_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
Restrict what the assistant can do by narrowing the scopes:
```json
"env": {
"JORTT_CLIENT_ID": "your-client-id",
"JORTT_CLIENT_SECRET": "your-client-secret",
"JORTT_SCOPES": "invoices:read customers:read reports:read"
}
```
## Tool overview
Tool names follow a predictable `verb_resource` convention derived from the API
path and version. A selection:
| Tool | Method & path | What it does |
| --------------------------- | ----------------------------------- | ----------------------------------------- |
| `list_customers` | `GET /customers` | List / search customers |
| `create_customers` | `POST /customers` | Create a customer |
| `get_customers` | `GET /customers/{customer_id}` | Fetch a single customer |
| `list_invoices` | `GET /invoices` | List / search invoices |
| `create_v3_invoices` | `POST /v3/invoices` | Create an invoice (invoice-level VAT) |
| `send_invoices` | `POST /invoices/{id}/send` | Send an invoice |
| `get_invoices_download` | `GET /invoices/{id}/download` | Get a download URL for the invoice PDF |
| `list_v2_estimates` | `GET /v2/estimates` | List estimates |
| `create_v2_estimates` | `POST /v2/estimates` | Create an estimate |
| `list_v3_expenses` | `GET /v3/expenses` | List expenses |
| `list_v3_bank_accounts` | `GET /v3/bank-accounts` | List bank accounts |
| `get_reports_summaries_profit_and_loss` | `GET /reports/summaries/profit_and_loss` | Dashboard profit & loss summary |
| `get_organizations_me` | `GET /organizations/me` | The organization tied to your credentials |
Newer resources are versioned (`v2`, `v3`) and the version is part of the tool
name. Where Jortt offers multiple versions of the same operation (for example
invoice creation), prefer the highest version unless you have a reason not to —
`create_v3_invoices` supports invoice-level VAT.
Run the server and call `tools/list` to see the full, always-current set with
per-tool argument schemas.
## Development
```bash
npm run build # compile TypeScript to dist/ (and copy the spec)
npm run dev # tsc --watch
npm run typecheck # type-check without emitting
```
### Updating to the latest API
The tool set is generated from the OpenAPI spec, which the build downloads
fresh each time. To pick up new Jortt endpoints, just rebuild:
```bash
npm run build
```
No code changes are needed — new operations become new tools automatically.
## Notes & limits
- Jortt rate-limits the API to ~10 requests/second. List endpoints paginate at a
maximum of 100 items per page (`page` argument).
- This is an unofficial, community project and is not affiliated with or endorsed
by Jortt.
## License
MIT — see [LICENSE](LICENSE).

1215
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "jortt-mcp",
"version": "0.1.0",
"description": "Model Context Protocol server for the Jortt accounting API",
"license": "MIT",
"type": "module",
"bin": {
"jortt-mcp": "dist/index.js"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"fetch-spec": "node scripts/fetch-spec.mjs",
"build": "npm run fetch-spec && tsc && node -e \"require('fs').copyFileSync('src/openapi.json','dist/openapi.json')\"",
"prepare": "npm run build",
"start": "node dist/index.js",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
},
"keywords": [
"mcp",
"model-context-protocol",
"jortt",
"accounting",
"invoicing",
"api"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
}
}

31
scripts/fetch-spec.mjs Normal file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env node
// Downloads the Jortt OpenAPI specification into src/openapi.json so it can be
// bundled with the build. Run automatically by `npm run build`.
import { writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SPEC_URL = process.env.JORTT_OPENAPI_URL || "https://api.jortt.nl/swagger_doc";
const target = join(__dirname, "..", "src", "openapi.json");
async function main() {
process.stderr.write(`Fetching Jortt OpenAPI spec from ${SPEC_URL} ...\n`);
const res = await fetch(SPEC_URL, { headers: { Accept: "application/json" } });
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const spec = await res.json();
if (!spec.paths || typeof spec.paths !== "object") {
throw new Error("Downloaded file does not look like an OpenAPI spec (no paths).");
}
await mkdir(dirname(target), { recursive: true });
await writeFile(target, JSON.stringify(spec));
const count = Object.keys(spec.paths).length;
process.stderr.write(`Wrote ${target} (${count} paths).\n`);
}
main().catch((err) => {
process.stderr.write(`Failed to fetch spec: ${err.message}\n`);
process.exit(1);
});

95
src/auth.ts Normal file
View file

@ -0,0 +1,95 @@
const TOKEN_URL = "https://app.jortt.nl/oauth-provider/oauth/token";
/**
* Default scopes requested for the client-credentials grant. The token server
* only grants scopes that were configured when the application was registered,
* so requesting the full set is safe: unavailable scopes are simply omitted.
*/
const DEFAULT_SCOPES = [
"customers:read",
"customers:write",
"estimates:read",
"estimates:write",
"expenses:read",
"expenses:write",
"financing:read",
"inbox:write",
"invoices:read",
"invoices:write",
"organizations:read",
"organizations:write",
"payroll:read",
"payroll:write",
"reports:read",
];
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
}
export class TokenManager {
private accessToken: string | null = null;
private expiresAt = 0;
private inflight: Promise<string> | null = null;
constructor(
private readonly clientId: string,
private readonly clientSecret: string,
private readonly scopes: string = DEFAULT_SCOPES.join(" "),
) {}
/** Return a valid bearer token, fetching or refreshing as needed. */
async getToken(): Promise<string> {
// 60s safety margin before actual expiry.
if (this.accessToken && Date.now() < this.expiresAt - 60_000) {
return this.accessToken;
}
// Coalesce concurrent refreshes into a single request.
if (this.inflight) return this.inflight;
this.inflight = this.fetchToken().finally(() => {
this.inflight = null;
});
return this.inflight;
}
/** Force the next getToken() call to re-fetch (used after a 401). */
invalidate(): void {
this.accessToken = null;
this.expiresAt = 0;
}
private async fetchToken(): Promise<string> {
const basic = Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64");
const body = new URLSearchParams({
grant_type: "client_credentials",
scope: this.scopes,
});
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to obtain Jortt access token (HTTP ${res.status}): ${text || res.statusText}`,
);
}
const json = (await res.json()) as TokenResponse;
this.accessToken = json.access_token;
this.expiresAt = Date.now() + json.expires_in * 1000;
return this.accessToken;
}
}

93
src/client.ts Normal file
View file

@ -0,0 +1,93 @@
import { TokenManager } from "./auth.js";
import type { JorttTool } from "./openapi.js";
const API_BASE = "https://api.jortt.nl";
export interface CallResult {
status: number;
ok: boolean;
data: unknown;
}
export class JorttClient {
constructor(
private readonly tokens: TokenManager,
private readonly baseUrl: string = API_BASE,
) {}
/** Execute a generated tool with the supplied arguments. */
async call(
tool: JorttTool,
args: Record<string, unknown>,
): Promise<CallResult> {
const url = this.buildUrl(tool, args);
const init = this.buildInit(tool, args);
return this.send(url, init);
}
private buildUrl(tool: JorttTool, args: Record<string, unknown>): string {
let path = tool.path;
for (const name of tool.pathParams) {
const value = args[name];
if (value === undefined || value === null || value === "") {
throw new Error(`Missing required path parameter: ${name}`);
}
path = path.replace(
`{${name}}`,
encodeURIComponent(String(value)),
);
}
const url = new URL(this.baseUrl + path);
for (const name of tool.queryParams) {
const value = args[name];
if (value === undefined || value === null || value === "") continue;
url.searchParams.set(name, String(value));
}
return url.toString();
}
private buildInit(
tool: JorttTool,
args: Record<string, unknown>,
): RequestInit {
const init: RequestInit = { method: tool.method.toUpperCase() };
if (tool.hasBody && args.body !== undefined) {
init.body = JSON.stringify(args.body);
}
return init;
}
private async send(url: string, init: RequestInit): Promise<CallResult> {
const doFetch = async (): Promise<Response> => {
const token = await this.tokens.getToken();
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: "application/json",
};
if (init.body) headers["Content-Type"] = "application/json";
return fetch(url, { ...init, headers });
};
let res = await doFetch();
if (res.status === 401) {
// Token may have been revoked or expired early; refresh once and retry.
this.tokens.invalidate();
res = await doFetch();
}
const text = await res.text();
let data: unknown = text;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
} else {
data = null;
}
return { status: res.status, ok: res.ok, data };
}
}

98
src/index.ts Normal file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { TokenManager } from "./auth.js";
import { JorttClient } from "./client.js";
import { generateTools, type JorttTool } from "./openapi.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
console.error(
`[jortt-mcp] Missing required environment variable: ${name}\n` +
`Set JORTT_CLIENT_ID and JORTT_CLIENT_SECRET with an application registered at\n` +
`https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list`,
);
process.exit(1);
}
return value;
}
async function main(): Promise<void> {
const clientId = requireEnv("JORTT_CLIENT_ID");
const clientSecret = requireEnv("JORTT_CLIENT_SECRET");
const scopes = process.env.JORTT_SCOPES; // optional space-separated override
const tokens = new TokenManager(clientId, clientSecret, scopes || undefined);
const client = new JorttClient(tokens);
const jorttTools = await generateTools();
const toolsByName = new Map<string, JorttTool>(
jorttTools.map((t) => [t.name, t]),
);
const server = new Server(
{ name: "jortt-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Tool[] = jorttTools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema as Tool["inputSchema"],
}));
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = toolsByName.get(request.params.name);
if (!tool) {
return {
isError: true,
content: [
{ type: "text", text: `Unknown tool: ${request.params.name}` },
],
};
}
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
try {
const result = await client.call(tool, args);
const payload = JSON.stringify(result.data, null, 2);
return {
isError: !result.ok,
content: [
{
type: "text",
text: result.ok
? payload
: `Jortt API error (HTTP ${result.status}):\n${payload}`,
},
],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
isError: true,
content: [{ type: "text", text: `Request failed: ${message}` }],
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(
`[jortt-mcp] ready — ${jorttTools.length} tools exposed over stdio`,
);
}
main().catch((err) => {
console.error("[jortt-mcp] fatal:", err);
process.exit(1);
});

285
src/openapi.ts Normal file
View file

@ -0,0 +1,285 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Where to fetch the spec from when no bundled copy is present. */
const DEFAULT_SPEC_URL = "https://api.jortt.nl/swagger_doc";
/** A single generated MCP tool derived from an OpenAPI operation. */
export interface JorttTool {
name: string;
description: string;
method: string;
/** Path template, e.g. "/invoices/{id}". */
path: string;
pathParams: string[];
queryParams: string[];
/** Whether this operation has a request body. */
hasBody: boolean;
inputSchema: JsonSchema;
}
export interface JsonSchema {
type?: string;
description?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
required?: string[];
enum?: unknown[];
example?: unknown;
format?: string;
[k: string]: unknown;
}
interface SwaggerSpec {
host: string;
schemes?: string[];
definitions: Record<string, JsonSchema>;
paths: Record<string, Record<string, SwaggerOperation>>;
}
interface SwaggerParameter {
name: string;
in: "path" | "query" | "body" | "header" | "formData";
required?: boolean;
description?: string;
type?: string;
format?: string;
enum?: unknown[];
schema?: JsonSchema;
}
interface SwaggerOperation {
operationId?: string;
summary?: string;
description?: string;
parameters?: SwaggerParameter[];
tags?: string[];
}
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
/** POST path segments that describe an action rather than a "create". */
const ACTION_SEGMENTS = new Set([
"send",
"copy",
"credit",
"accept",
"reject",
"invoice",
"receipt",
"images",
"direct_debit_mandate",
]);
let cachedSpec: SwaggerSpec | null = null;
/**
* Load the OpenAPI spec. Prefers the copy bundled next to the compiled module
* (produced at build time); if that is missing, fetches it live from Jortt so
* the server still works from a bare checkout. Override the source with
* `JORTT_OPENAPI_URL`.
*/
async function loadSpec(): Promise<SwaggerSpec> {
if (cachedSpec) return cachedSpec;
try {
const raw = await readFile(join(__dirname, "openapi.json"), "utf8");
cachedSpec = JSON.parse(raw) as SwaggerSpec;
return cachedSpec;
} catch {
// No bundled spec — fall back to fetching it.
}
const url = process.env.JORTT_OPENAPI_URL || DEFAULT_SPEC_URL;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) {
throw new Error(
`Could not load the Jortt OpenAPI spec from ${url} (HTTP ${res.status}). ` +
`Run \`npm run build\` to bundle it, or set JORTT_OPENAPI_URL.`,
);
}
cachedSpec = (await res.json()) as SwaggerSpec;
return cachedSpec;
}
/** Recursively inline `$ref` references into a plain JSON Schema, guarding cycles. */
function resolveSchema(
schema: JsonSchema,
definitions: Record<string, JsonSchema>,
seen: Set<string> = new Set(),
): JsonSchema {
if (schema == null || typeof schema !== "object") return schema;
const ref = (schema as { $ref?: string }).$ref;
if (ref) {
const key = ref.replace("#/definitions/", "");
if (seen.has(key)) {
// Cycle: return a shallow object placeholder rather than recursing forever.
return { type: "object", description: `(recursive reference to ${key})` };
}
const target = definitions[key];
if (!target) return { type: "object" };
const nextSeen = new Set(seen);
nextSeen.add(key);
return resolveSchema(target, definitions, nextSeen);
}
const out: JsonSchema = {};
for (const [k, v] of Object.entries(schema)) {
if (k === "properties" && v && typeof v === "object") {
const props: Record<string, JsonSchema> = {};
for (const [pk, pv] of Object.entries(v as Record<string, JsonSchema>)) {
props[pk] = resolveSchema(pv, definitions, seen);
}
out.properties = props;
} else if (k === "items" && v && typeof v === "object") {
out.items = resolveSchema(v as JsonSchema, definitions, seen);
} else if (k === "allOf" && Array.isArray(v)) {
// Merge allOf members into a single object schema.
const merged: JsonSchema = { type: "object", properties: {} };
for (const member of v) {
const resolved = resolveSchema(member as JsonSchema, definitions, seen);
Object.assign(merged.properties!, resolved.properties ?? {});
if (resolved.required) {
merged.required = [...(merged.required ?? []), ...resolved.required];
}
}
return merged;
} else {
(out as Record<string, unknown>)[k] = v;
}
}
return out;
}
/** Build a stable, readable, snake_case tool name from an HTTP method + path. */
function toolName(method: string, path: string, summary = ""): string {
const rawSegments = path.split("/").filter(Boolean);
const literals: string[] = [];
let lastLiteral = "";
for (const seg of rawSegments) {
if (seg.startsWith("{")) continue;
const clean = seg.replace(/-/g, "_");
literals.push(clean);
lastLiteral = clean;
}
const descriptor = literals.join("_");
const summaryLc = summary.toLowerCase();
let verb: string;
switch (method) {
case "get":
// The summary distinguishes collection listings from single-resource
// reads far more reliably than path shape alone.
verb =
summaryLc.startsWith("list") || summaryLc.includes("returns a list")
? "list"
: "get";
break;
case "post":
if (ACTION_SEGMENTS.has(lastLiteral)) {
// Action lives in the path (e.g. .../send); use it as the verb and
// drop it from the descriptor to avoid duplication.
const trimmed = literals.slice(0, -1).join("_");
return `${lastLiteral}_${trimmed}`.replace(/_+$/, "");
}
verb = "create";
break;
case "put":
case "patch":
verb = "update";
break;
case "delete":
verb = "delete";
break;
default:
verb = method;
}
return `${verb}_${descriptor}`;
}
function primitiveSchema(p: SwaggerParameter): JsonSchema {
const s: JsonSchema = { type: p.type ?? "string" };
if (p.description) s.description = p.description;
if (p.format) s.format = p.format;
if (p.enum) s.enum = p.enum;
return s;
}
/** Generate the full list of MCP tools from the Jortt OpenAPI spec. */
export async function generateTools(): Promise<JorttTool[]> {
const spec = await loadSpec();
const tools: JorttTool[] = [];
const usedNames = new Set<string>();
for (const [path, methods] of Object.entries(spec.paths)) {
// The unversioned paths are aliases of the /v1/* paths; keep only one set.
if (path.startsWith("/v1/")) continue;
for (const [method, op] of Object.entries(methods)) {
if (!HTTP_METHODS.includes(method)) continue;
const params = op.parameters ?? [];
const pathParams: string[] = [];
const queryParams: string[] = [];
const properties: Record<string, JsonSchema> = {};
const required: string[] = [];
let hasBody = false;
for (const p of params) {
if (p.in === "path") {
pathParams.push(p.name);
properties[p.name] = primitiveSchema(p);
required.push(p.name);
} else if (p.in === "query") {
queryParams.push(p.name);
properties[p.name] = primitiveSchema(p);
if (p.required) required.push(p.name);
} else if (p.in === "body" && p.schema) {
hasBody = true;
const bodySchema = resolveSchema(p.schema, spec.definitions);
bodySchema.description =
p.description ?? bodySchema.description ?? "Request body";
properties.body = bodySchema;
if (p.required) required.push("body");
}
}
let name = toolName(method, path, op.summary);
if (usedNames.has(name)) {
let i = 2;
while (usedNames.has(`${name}_${i}`)) i++;
name = `${name}_${i}`;
}
usedNames.add(name);
const summary = op.summary?.trim();
const desc = op.description?.trim();
const description =
[summary, desc && desc !== summary ? desc : null]
.filter(Boolean)
.join("\n\n") || `${method.toUpperCase()} ${path}`;
tools.push({
name,
description: `${description}\n\n(${method.toUpperCase()} ${path})`,
method,
path,
pathParams,
queryParams,
hasBody,
inputSchema: {
type: "object",
properties,
...(required.length ? { required } : {}),
},
});
}
}
tools.sort((a, b) => a.name.localeCompare(b.name));
return tools;
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*.ts"]
}