Skip to content
36 changes: 21 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

A **read-only** Model Context Protocol (MCP) server that exposes Pro Cycling Manager
(PCM) game saves to an LLM client over stdio. PCM stores careers as binary `.cdb`
files; this server discovers and inspects those saves but **never writes to or
modifies them**. Each call re-reads the `.cdb` from disk and loads it into an
in-memory sql.js (SQLite) database (via `cdb-converter`), so the on-disk save is
the single source of truth and is never mutated. Any new tool must keep this
read-only guarantee.
A Model Context Protocol (MCP) server that exposes Pro Cycling Manager (PCM) game
saves to an LLM client over stdio. PCM stores careers as binary `.cdb` files; this
server discovers and inspects those saves and **never modifies the source save**.
Each call re-reads the `.cdb` from disk and loads it into an in-memory sql.js
(SQLite) database (via `cdb-converter`), so the on-disk save is the single source
of truth. Write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) mutate the
in-memory copy and serialize it to a **new** `.cdb` via `writeSaveDb`, which
refuses to overwrite the source or any existing file. Any new tool must keep this
never-touch-the-source guarantee.

## Stack

Expand All @@ -26,13 +28,13 @@ read-only guarantee.
```
src/
index.ts # entrypoint: builds McpServer, registers tools, connects stdio
saves.ts # save discovery + validation (listSaves, validateSave, getPcmRoot)
save-db.ts # withSaveDb(): open .cdb in-memory, run fn, always close db; getGameDate()
helpers.ts # validResponse / errorResponse → CallToolResult; ageFromYmd(); buildStartlistXml
saves.ts # discover .cdb saves on disk and validate paths passed by tools
save-db.ts # everything touching the database: open a save in memory, serialize an edited copy, schema/game-date introspection
helpers.ts # cross-cutting utilities: MCP tool responses, SQL statement parsing/errors, dates, startlist XML
schemas/
cyclist.ts # shared cyclist ratings: ratingsSchema / ratingsColumns() / mapRatings()
cyclist.ts # shared cyclist ratings schema and its SQL read/write mappings
tools/
index.ts # registerTools() — wires every tool onto the server
index.ts # wires every tool onto the server
list-saves.ts # pcm_list_saves
select-save.ts # pcm_select_save
get-save-schema.ts # pcm_get_save_schema
Expand All @@ -42,13 +44,15 @@ src/
search-cyclist.ts # pcm_search_cyclist
search-team.ts # pcm_search_team
query-save.ts # pcm_query_save
update-save.ts # pcm_update_save
update-cyclist-ratings.ts # pcm_update_cyclist_ratings
generate-startlist-xml.ts # pcm_generate_startlist_xml
test/ # vitest specs (test/**/*.test.ts)
```

## Tools

All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructiveHint: false` annotations so clients can auto-approve them.
All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `destructiveHint: false` annotations so clients can auto-approve them; the two write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) carry `readOnlyHint: false` / `destructiveHint: true`.

| Tool | Purpose |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -61,6 +65,8 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive
| `pcm_search_cyclist` | Search cyclist by first/last name (partial, case-insensitive). |
| `pcm_search_team` | Search team by name (partial, case-insensitive; matches full name and short name). |
| `pcm_query_save` | Run a single read-only `SELECT`/`WITH … SELECT`. Write/DDL rejected; results capped (default 100, max 1000). |
| `pcm_update_save` | Apply a single `INSERT`/`UPDATE`/`DELETE` to a save and write the result to a **new** `.cdb` (`outputPath` must differ from `savePath`). SELECT/DDL/stacked statements rejected. |
| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 55–85) and write the result to a **new** `.cdb`. Returns the cyclist's full ratings after the update. |
| `pcm_generate_startlist_xml` | Build a PCM startlist XML from teams + rosters; derives the file name from `STA_race.gene_sz_filename` for the given `IDrace`. |

## Conventions
Expand All @@ -80,8 +86,8 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive
- **Tool responses** go through `validResponse` / `errorResponse`; declare both
`inputSchema` and `outputSchema` with zod.
- **Tool annotations** — every tool must include `readOnlyHint`, `destructiveHint`,
`idempotentHint`, and `openWorldHint`. All current tools are read-only
(`readOnlyHint: true`, `destructiveHint: false`).
`idempotentHint`, and `openWorldHint`. Read tools use `readOnlyHint: true` /
`destructiveHint: false`; write tools the inverse.
- **Tool naming** — all tools are prefixed with `pcm_` (e.g. `pcm_list_saves`) to
avoid conflicts when used alongside other MCP servers.
- **Platform:** auto-discovery is Windows-only. On macOS/Linux (Wine/Proton),
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +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 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.
> 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 write tools, `pcm_update_save` and `pcm_update_cyclist_ratings`, serialize their changes to a **new** `.cdb` file (`outputPath`) and refuse 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.
- **Guarded edits** — apply a single `INSERT`/`UPDATE`/`DELETE`, or edit a cyclist's ratings directly, 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** — read tools are annotated `readOnlyHint: true` for auto-approval; the write tool writes only to a separate output file.
- **Safe by design** — read tools are annotated `readOnlyHint: true` for auto-approval; the write tools write only to a separate output file and never overwrite an existing one.

## Getting started

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

## Available tools

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.
All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm_update_cyclist_ratings` is read-only and carries `readOnlyHint: true` so clients like Claude Desktop can approve them automatically without a confirmation prompt. The write tools never overwrite the source save.

| Tool | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand All @@ -95,15 +95,16 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` is read-
| **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_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (`plain`, `mountain`, `mediumMountain`, `downhilling`, `cobble`, `timeTrial`, `prologue`, `sprint`, `acceleration`, `endurance`, `resistance`, `recuperation`, `hill`, `baroudeur`; 55–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. |
| **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 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:
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 the write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) write their 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, or `pcm_update_save` to write an edited copy of the save.
3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_cyclist_ratings` / `pcm_update_save` to write an edited copy of the save.

## Development

Expand Down
17 changes: 17 additions & 0 deletions src/save-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export function getGameDate(db: SaveDb): number | null {
}
}

/**
* Column names of `tableName` as a Set, via `PRAGMA table_info`.
*
* Some columns are absent on saves that pre-date them — check membership with
* `.has()` so queries stay valid across PCM versions. Returns an empty set for
* unknown tables.
*/
export function getTableColumnNames(
db: SaveDb,
tableName: string,
): Set<string> {
const columnInfo = db.exec(
`PRAGMA table_info("${tableName.replaceAll('"', '""')}")`,
);
return new Set((columnInfo[0]?.values ?? []).map((r) => String(r[1])));
}

/**
* Open a Pro Cycling Manager `.cdb` save as an in-memory SQL database, run
* `fn`, and wrap the result in an MCP tool response.
Expand Down
24 changes: 24 additions & 0 deletions src/schemas/cyclist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ export const ratingsSchema = z.object({
baroudeur: z.number().describe("Baroudeur rating (charac_i_baroudeur)"),
});

/**
* Maps each {@link ratingsSchema} field to its `DYN_cyclist` column. Single
* source of truth for tools that write ratings back (the read path keeps its
* own aliased fragment in {@link ratingsColumns}).
*/
export const ratingColumns = {
plain: "charac_i_plain",
mountain: "charac_i_mountain",
mediumMountain: "charac_i_medium_mountain",
downhilling: "charac_i_downhilling",
cobble: "charac_i_cobble",
timeTrial: "charac_i_timetrial",
prologue: "charac_i_prologue",
sprint: "charac_i_sprint",
acceleration: "charac_i_acceleration",
endurance: "charac_i_endurance",
resistance: "charac_i_resistance",
recuperation: "charac_i_recuperation",
hill: "charac_i_hill",
baroudeur: "charac_i_baroudeur",
} as const satisfies Record<keyof z.infer<typeof ratingsSchema>, string>;

export type RatingField = keyof typeof ratingColumns;

/** SQL `SELECT` fragment that aliases the rating columns to {@link ratingsSchema}'s
* field names. `mediumMountain` falls back to `NULL` on saves that pre-date the
* `charac_i_medium_mountain` column. */
Expand Down
9 changes: 2 additions & 7 deletions src/tools/get-team-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { mapRatings, ratingsColumns, ratingsSchema } from "../schemas/cyclist";
import { ageFromYmd } from "../helpers";
import { getGameDate, withSaveDb } from "../save-db";
import { getGameDate, getTableColumnNames, withSaveDb } from "../save-db";

const cyclistSchema = z.object({
id: z.number().describe("Cyclist ID (IDcyclist)"),
Expand Down Expand Up @@ -117,12 +117,7 @@ export function registerGetTeamRoster(server: McpServer): void {
// The current in-game date (YYYYMMDD) is the reference point for age.
const currentYmd = getGameDate(db);

// Some columns are absent on saves that pre-date them — detect them so
// the query stays valid across PCM versions.
const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`);
const columnNames = new Set(
(columnInfo[0]?.values ?? []).map((r) => String(r[1])),
);
const columnNames = getTableColumnNames(db, "DYN_cyclist");
const hasCurrentAbility = columnNames.has("value_f_current_ability");
const hasCapital = columnNames.has("value_f_capital");
const hasMediumMountain = columnNames.has("charac_i_medium_mountain");
Expand Down
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 { registerUpdateCyclistRatings } from "./update-cyclist-ratings";
import { registerUpdateSave } from "./update-save";
import { registerSearchCyclist } from "./search-cyclist";
import { registerGenerateStartlistXml } from "./generate-startlist-xml";
Expand All @@ -20,6 +21,7 @@ export function registerTools(server: McpServer): void {
registerGetTeamRoster(server);
registerQuerySave(server);
registerUpdateSave(server);
registerUpdateCyclistRatings(server);
registerSearchCyclist(server);
registerGenerateStartlistXml(server);
registerSearchTeam(server);
Expand Down
7 changes: 2 additions & 5 deletions src/tools/search-cyclist.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { mapRatings, ratingsColumns, ratingsSchema } from "../schemas/cyclist";
import { withSaveDb } from "../save-db";
import { getTableColumnNames, withSaveDb } from "../save-db";

const cyclistSchema = z.object({
id: z.number().describe("Cyclist ID (IDcyclist)"),
Expand Down Expand Up @@ -56,10 +56,7 @@ export function registerSearchCyclist(server: McpServer): void {
},
async ({ savePath, firstName = "", lastName = "" }) =>
withSaveDb(savePath, (db) => {
const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`);
const columnNames = new Set(
(columnInfo[0]?.values ?? []).map((r) => String(r[1])),
);
const columnNames = getTableColumnNames(db, "DYN_cyclist");
const hasMediumMountain = columnNames.has("charac_i_medium_mountain");
const hasCurrentAbility = columnNames.has("value_f_current_ability");

Expand Down
Loading
Loading