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

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;
}