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:
commit
240a53fade
12 changed files with 2057 additions and 0 deletions
6
.env.example
Normal file
6
.env.example
Normal 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
8
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
148
README.md
Normal 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
1215
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal 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
31
scripts/fetch-spec.mjs
Normal 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
95
src/auth.ts
Normal 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
93
src/client.ts
Normal 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
98
src/index.ts
Normal 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
285
src/openapi.ts
Normal 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
17
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue