Skip to content
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@
`pcm-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI assistants such as Claude Desktop, ChatGPT and Gemini query your [Pro Cycling Manager](https://www.cyanide-studio.com/) (PCM) game saves. Ask about a rider's ratings, browse a team's roster, run SQL against the save, or generate a race startlist — all in plain language.

> [!IMPORTANT]
> This server is strictly **read-only**. PCM stores careers as binary `.cdb` files; each call re-reads the `.cdb` from disk and loads it into an **in-memory** SQLite database. Your save files are **never written to or modified**.
> This server never modifies your existing save files. PCM stores careers as binary `.cdb` files; each call re-reads the `.cdb` from disk and loads it into an **in-memory** SQLite database. Every read tool leaves the source untouched. The single write tool, `pcm_update_save`, serializes its changes to a **new** `.cdb` file (`outputPath`) and refuses to overwrite the input — keep your original save as a backup.

## Features

- **Zero setup** — run it with a single `npx` command, or install a `.mcpb` bundle with no terminal at all.
- **Save discovery** — auto-detect PCM career saves on Windows, or point at any `.cdb` file directly.
- **Rich queries** — search cyclists and teams, inspect rosters with full per-terrain ratings, and read player info.
- **Raw SQL** — run guarded, read-only `SELECT` queries against any table in the save.
- **Guarded edits** — apply a single `INSERT`/`UPDATE`/`DELETE` and write the result to a new `.cdb`, never touching the original.
- **Startlist export** — generate a PCM-ready startlist XML from a set of teams and rosters.
- **Safe by design** — every tool is annotated `readOnlyHint: true`, so clients can auto-approve them without prompts.
- **Safe by design** — read tools are annotated `readOnlyHint: true` for auto-approval; the write tool writes only to a separate output file.

## Getting started

Expand Down Expand Up @@ -80,7 +81,7 @@ Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linu

## Available tools

All tools are prefixed with `pcm_`, are read-only, and carry `readOnlyHint: true` so clients like Claude Desktop can approve them automatically without a confirmation prompt.
All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` is read-only and carries `readOnlyHint: true` so clients like Claude Desktop can approve them automatically without a confirmation prompt. `pcm_update_save` is the one write tool; it never overwrites the source save.

| Tool | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand All @@ -93,15 +94,16 @@ All tools are prefixed with `pcm_`, are read-only, and carry `readOnlyHint: true
| **pcm_get_team_roster** | List a team's roster (defaults to the active player's team when `teamId` is omitted). Joins DYN_cyclist with its active DYN_contract_cyclist and STA_type_rider; per rider returns name, country, age (derived from birth date and the current game date), rider type, overall ability, contract end year, wage, market value and all per-terrain ability ratings. Ordered by overall ability, highest first. Errors if `teamId` does not exist. |
| **pcm_search_team** | Search for a team by name (case-insensitive partial match against both the full name and short name). Returns up to 10 matches with the resolved division name, country name, evaluation and general manager. |
| **pcm_query_save** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table in a save file. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). |
| **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. |
| **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). |

## How it works

Tools are **stateless**: there is no "current save" held by the server. Every save-reading tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The on-disk save is the single source of truth and is never mutated. A typical flow is:
Tools are **stateless**: there is no "current save" held by the server. Every tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The source save on disk is never mutated: read tools only ever read it, and `pcm_update_save` writes its changes to a separate output `.cdb`. A typical flow is:

1. `pcm_list_saves` (Windows) or `pcm_select_save` with an explicit path to locate a save.
2. `pcm_search_cyclist`, `pcm_get_team_roster`, `pcm_query_save`, … to explore it.
3. `pcm_generate_startlist_xml` to produce a startlist file for a race.
3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_save` to write an edited copy of the save.

## Development

Expand Down
72 changes: 72 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { type Result as IdentifyResult, identify } from "sql-query-identifier";

export function validResponse(
structured:
Expand Down Expand Up @@ -60,6 +61,77 @@ export function buildStartlistXml(teams: StartlistTeam[]): string {
return `${lines.join("\n")}\n`;
}

/**
* Translate sql.js "no such table/column" errors into actionable messages that
* point the caller at the schema-discovery tools. Other errors pass through.
*
* Shared by the read (`pcm_query_save`) and write (`pcm_update_save`) tools.
*/
export function explainQueryError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);

const missingTable = /no such table:\s*(\S+)/i.exec(message);
if (missingTable) {
return new Error(
`Table "${missingTable[1]}" does not exist in this save — use pcm_get_save_schema to list available tables.`,
);
}

const missingColumn = /no such column:\s*(\S+)/i.exec(message);
if (missingColumn) {
return new Error(
`Column "${missingColumn[1]}" does not exist — use pcm_get_table_schema to inspect the table's columns.`,
);
}

// Raised by `PRAGMA query_only = ON` when a statement tries to write.
if (/readonly database|not authorized/i.test(message)) {
return new Error(
"This tool is read-only — the query attempted to modify the save, which is not allowed.",
);
}

return error instanceof Error ? error : new Error(message);
}

/**
* Normalize `raw` to a single SQL statement and parse it with
* `sql-query-identifier`.
*
* Strips one trailing `;`, then rejects empty input and stacked statements
* (`label` — e.g. "Query" or "Statement" — is used in the empty-input message).
* Because the parser tokenizes SQL properly, a `;` inside a string literal,
* comment or quoted identifier is not mistaken for a statement separator.
*
* Returns the normalized text (safe to prepare/run) and the parsed statement;
* callers decide which statement kinds they allow (via `type`/`executionType`).
*
*/
export function parseSingleStatement(
raw: string,
label: string,
): { text: string; statement: IdentifyResult } {
const text = raw.trim().replace(/;\s*$/, "");

if (text.length === 0) {
throw new Error(`${label} is empty.`);
}

const statements = identify(text, { strict: false, dialect: "sqlite" });

if (statements.length === 0) {
throw new Error(`${label} is empty.`);
}

if (statements.length > 1) {
throw new Error(
"Only a single statement is allowed — remove extra semicolons.",
);
}

return { text, statement: statements[0] };
}

/** Compute age in whole years from two YYYYMMDD integers (e.g. 20030503). */
export function ageFromYmd(currentYmd: number, birthYmd: number): number {
let age = Math.floor(currentYmd / 10000) - Math.floor(birthYmd / 10000);
Expand Down
102 changes: 98 additions & 4 deletions src/save-db.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { readFile, stat, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { cdbToSql } from "cdb-converter";
import { cdbToSql, sqlToCdb } from "cdb-converter";
import initSqlJs from "sql.js";
import { errorResponse, validResponse } from "./helpers";
import { type SaveFile, validateSave } from "./saves";
Expand Down Expand Up @@ -48,7 +49,10 @@ export function getGameDate(db: SaveDb): number | null {
* - turns thrown errors into an {@link errorResponse} and the returned value
* into a {@link validResponse}.
*
* The save is loaded into memory only; changes are never written back to disk.
* `withSaveDb` itself never writes to `savePath`: the on-disk source save is
* only ever read. A write-capable tool can pass `{ queryOnly: false }`, mutate
* the in-memory database in `fn`, and serialize the result to a *separate*
* output file via {@link writeSaveDb} — the source is never overwritten.
*
* @param savePath - Absolute path to the `.cdb` save file.
* @param fn - Receives the open database and the validated save metadata, and
Expand All @@ -57,6 +61,9 @@ export function getGameDate(db: SaveDb): number | null {
export async function withSaveDb<T extends Record<string, unknown>>(
savePath: string,
fn: (db: SaveDb, save: SaveFile) => T | Promise<T>,
config: {
queryOnly?: boolean;
} = {},
): Promise<CallToolResult> {
let db: SaveDb | undefined;
try {
Expand All @@ -66,7 +73,9 @@ export async function withSaveDb<T extends Record<string, unknown>>(
const cdbBuffer = await readFile(save.path);
db = cdbToSql(cdbBuffer, SQL);

db.run("PRAGMA query_only = ON;");
if (config.queryOnly ?? true) {
db.run("PRAGMA query_only = ON;");
}

const output = await fn(db, save);

Expand All @@ -79,3 +88,88 @@ export async function withSaveDb<T extends Record<string, unknown>>(
db?.close();
}
}

/**
* Serialize an edited in-memory save back to a `.cdb` file at `outputPath`.
*
* Writes only ever go to a new file: this refuses to overwrite the source save
* (`sourcePath`), so the input `.cdb` is never modified. `sqlToCdb` re-encodes
* the sql.js database into PCM's compressed `.cdb` binary format.
*
* @param db - The (edited) in-memory database to serialize.
* @param outputPath - Absolute path of the `.cdb` file to write.
* @param sourcePath - Absolute path of the source save, used only to guard
* against overwriting it.
* @returns The absolute path written.
* @throws if `outputPath` isn't a `.cdb` file, resolves to `sourcePath`, points
* into a missing directory, or would overwrite an existing file.
*/
export async function writeSaveDb(
db: SaveDb,
outputPath: string,
sourcePath: string,
): Promise<string> {
if (!outputPath.toLowerCase().endsWith(".cdb")) {
throw new Error(`Output must be a .cdb file: ${outputPath}`);
}

const resolvedOutput = resolve(outputPath);
if (resolvedOutput === resolve(sourcePath)) {
throw new Error(
"outputPath must differ from the source save — the input .cdb is never overwritten.",
);
}

// Never clobber an existing file: writes only ever create a new `.cdb`.
if (await pathExists(resolvedOutput)) {
throw new Error(
`outputPath already exists: ${resolvedOutput} — choose a new file name so no existing file is overwritten.`,
);
}

// Fail early with an actionable message rather than a raw ENOENT from writeFile.
const parent = dirname(resolvedOutput);
if (!(await isDirectory(parent))) {
throw new Error(
`Output directory does not exist: ${parent} — create it first or point outputPath at an existing directory.`,
);
}

const cdb = sqlToCdb(db);
try {
await writeFile(resolvedOutput, Buffer.from(cdb), { flag: "wx" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error(
`outputPath already exists: ${resolvedOutput} — choose a new file name so no existing file is overwritten.`,
);
}
throw error;
}
return resolvedOutput;
Comment thread
Copilot marked this conversation as resolved.
}

/** True if `path` exists (file or directory). */
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
Comment thread
Copilot marked this conversation as resolved.

/** True if `path` exists and is a directory. */
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
Comment thread
Copilot marked this conversation as resolved.
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { registerGetTableSchema } from "./get-table-schema";
import { registerGetPlayerInfo } from "./get-player-info";
import { registerGetTeamRoster } from "./get-team-roster";
import { registerQuerySave } from "./query-save";
import { registerUpdateSave } from "./update-save";
import { registerSearchCyclist } from "./search-cyclist";
import { registerGenerateStartlistXml } from "./generate-startlist-xml";
import { registerSearchTeam } from "./search-team";
Expand All @@ -18,6 +19,7 @@ export function registerTools(server: McpServer): void {
registerGetPlayerInfo(server);
registerGetTeamRoster(server);
registerQuerySave(server);
registerUpdateSave(server);
registerSearchCyclist(server);
registerGenerateStartlistXml(server);
registerSearchTeam(server);
Expand Down
70 changes: 10 additions & 60 deletions src/tools/query-save.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { identify } from "sql-query-identifier";
import { z } from "zod";
import { explainQueryError, parseSingleStatement } from "../helpers";
import { withSaveDb } from "../save-db";

const DEFAULT_LIMIT = 100;
Expand Down Expand Up @@ -89,74 +89,24 @@ export function registerQuerySave(server: McpServer): void {
);
}

/**
* Translate sql.js "no such table/column" errors into actionable messages that
* point the caller at the schema-discovery tools. Other errors pass through.
*/
function explainQueryError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);

const missingTable = /no such table:\s*(\S+)/i.exec(message);
if (missingTable) {
return new Error(
`Table "${missingTable[1]}" does not exist in this save — use pcm_get_save_schema to list available tables.`,
);
}

const missingColumn = /no such column:\s*(\S+)/i.exec(message);
if (missingColumn) {
return new Error(
`Column "${missingColumn[1]}" does not exist — use pcm_get_table_schema to inspect the table's columns.`,
);
}

// Raised by `PRAGMA query_only = ON` when a statement tries to write.
if (/readonly database|not authorized/i.test(message)) {
return new Error(
"This tool is read-only — the query attempted to modify the save, which is not allowed.",
);
}

return error instanceof Error ? error : new Error(message);
}

/**
* Enforce that a query is a single read-only statement.
*
* Parsing is delegated to `sql-query-identifier`, which tokenizes SQL properly:
* a `;` inside a string literal, comment or quoted identifier is not mistaken
* for a statement separator, and CTEs are classified by their leaf operation —
* `WITH … SELECT` reads (`LISTING`) while `WITH … DELETE` writes
* (`MODIFICATION`). Anything that isn't exactly one `LISTING` statement is
* rejected here; `PRAGMA query_only = ON` (see {@link withSaveDb}) stays as the
* engine-level backstop.
* Parsing is delegated to {@link parseSingleStatement}, so a `;` inside a string
* literal, comment or quoted identifier is not mistaken for a statement
* separator. CTEs are classified by their leaf operation, so `WITH … SELECT`
* reads (`LISTING`) while `WITH … DELETE` writes (`MODIFICATION`) — only the
* former is accepted. `PRAGMA query_only = ON` (see {@link withSaveDb}) stays as
* the engine-level backstop.
*/
export function assertReadOnlyQuery(rawQuery: string): string {
// Strip a single trailing semicolon so a normal `SELECT …;` is accepted;
// the returned query is what gets prepared.
const query = rawQuery.trim().replace(/;\s*$/, "");

if (query.length === 0) {
throw new Error("Query is empty.");
}

const statements = identify(query, { strict: false, dialect: "sqlite" });

if (statements.length === 0) {
throw new Error("Query is empty.");
}

if (statements.length > 1) {
throw new Error(
"Only a single statement is allowed — remove extra semicolons.",
);
}
const { text, statement } = parseSingleStatement(rawQuery, "Query");

if (statements[0].executionType !== "LISTING") {
if (statement.executionType !== "LISTING") {
throw new Error(
"Only read-only SELECT (or WITH … SELECT) queries are allowed.",
);
}

return query;
return text;
}
Loading
Loading