From b8ee6f1f2f15d678fec80b367fd64dbd2c9dd819 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Wed, 8 Jul 2026 18:17:30 -0400 Subject: [PATCH 01/10] docs: use "save" instead of "career save" in README Aligns terminology across the README with the actual PCM save concept, since the tools operate on saves in general, not just career mode. Co-Authored-By: Claude Sonnet 5 --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 257494d..0a76309 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Pro Cycling Manager MCP Server

- Explore your Pro Cycling Manager career saves with an AI assistant — riders, teams, rosters and startlists, straight from the game's database. + Explore your Pro Cycling Manager saves with an AI assistant — riders, teams, rosters and startlists, straight from the game's database.

@@ -18,12 +18,12 @@ `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 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. +> This server never modifies your existing save files. PCM stores saves 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. +- **Save discovery** — auto-detect PCM 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`, or edit a cyclist's ratings directly, and write the result to a new `.cdb`, never touching the original. @@ -35,7 +35,7 @@ ### Prerequisites - [Node.js](https://nodejs.org) 22 or later (not required for the `.mcpb` bundle install) -- A Pro Cycling Manager career save (a `.cdb` file) +- A Pro Cycling Manager save (a `.cdb` file) ### Install @@ -71,7 +71,7 @@ Once configured, restart your client and ask it something like _"list my PCM sav ## Platform support -PCM only ships on Windows, where careers live under: +PCM only ships on Windows, where saves live under: ``` %APPDATA%/Pro Cycling Manager /Cloud// @@ -85,7 +85,7 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm | Tool | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **pcm_list_saves** | Discover PCM `.cdb` career save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%` (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). | +| **pcm_list_saves** | Discover PCM `.cdb` save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%` (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). | | **pcm_validate_save** | Validate that an absolute path points to an existing `.cdb` save file and return its metadata. Stateless — keep the returned path in conversation context to pass to later tools. | | **pcm_get_save_schema** | List every table inside a `.cdb` save file, with its ID and name, plus the total table count. | | **pcm_get_table_schema** | Inspect a single table by name. Returns its columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_get_save_schema` first to discover available table names. | From 6d804dc0d6a3d5293be51df3ac77b07bdf968b52 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Wed, 8 Jul 2026 18:29:40 -0400 Subject: [PATCH 02/10] docs: update terminology from "career save" to "save file" in documentation --- .claude/skills/pcm-startlist/SKILL.md | 12 ++++++++++-- AGENTS.md | 4 ++-- README.md | 2 +- manifest.json | 2 +- src/index.ts | 1 + src/saves.ts | 12 ++++++------ src/tools/list-saves.ts | 2 +- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.claude/skills/pcm-startlist/SKILL.md b/.claude/skills/pcm-startlist/SKILL.md index 25fbf4d..708389c 100644 --- a/.claude/skills/pcm-startlist/SKILL.md +++ b/.claude/skills/pcm-startlist/SKILL.md @@ -13,7 +13,7 @@ description: >- # PCM startlist builder Compose a startlist for a Pro Cycling Manager race and write the `.xml` file PCM -imports. This skill owns the *workflow* (find the race, choose teams, choose +imports. This skill owns the _workflow_ (find the race, choose teams, choose riders); the deterministic serialization is delegated to the `pcm_generate_startlist_xml` MCP tool — never hand-write the XML. @@ -35,12 +35,15 @@ returned by the tool — don't invent it. ## Workflow ### 1. Get a save path + Every step reads a `.cdb` save. If the user hasn't given an absolute `savePath`: + - Try `pcm_list_saves` (Windows only — fails on macOS/Linux Wine/Proton prefixes). - Otherwise ask the user for the absolute `.cdb` path. Keep it in context; the tools are stateless and need it on every call. ### 2. Identify the race (get `IDrace`) + The user names a race; resolve it to an `IDrace` with `pcm_query_save`: ```sql @@ -53,6 +56,7 @@ If several match, show the candidates (name + id) and let the user pick. Confirm the `gene_sz_filename` so the user knows the output file name up front. ### 3. Decide which teams take part + Either the user supplies the teams, or you propose them. Resolve names to `IDteam`: @@ -65,6 +69,7 @@ A typical startlist has ~18–25 teams. If the user just says "the usual teams", ask which division/tier or list candidates rather than guessing. ### 4. Pick riders per team + A team's full squad is the candidate pool — a startlist usually brings **7** of them (the count is free; the example pack mixes 6 and 7). Get a team's roster: @@ -82,11 +87,12 @@ the user has preferences (leaders, exclusions), apply them. Confirm the selectio before generating when there's any ambiguity. ### 5. Generate the file + Call `pcm_generate_startlist_xml` with `savePath`, `raceId`, and `teams`: ```json { - "savePath": "/abs/path/Career.cdb", + "savePath": "/abs/path/OfficialRelease.cdb", "raceId": 128, "teams": [ { "id": 34, "cyclists": [7602, 5996, 1381, 3291, 6342, 3912, 5613] }, @@ -99,11 +105,13 @@ The tool returns `{ fileName, xml }`. If a team has no riders or `teams` is empt the tool errors — fix the selection and retry. ### 6. Deliver + Present the returned `fileName` and `xml`. Offer to write it to disk (e.g. the user's PCM `Startlists`/race-import folder or the working directory) — the MCP server is read-only and does not write files, so saving is done outside it. ## Notes + - All PCM MCP tools are read-only; this workflow never modifies the save. - IDs, not names, go into the XML — always resolve names to `IDteam`/`IDcyclist` via the queries above before calling the tool. diff --git a/AGENTS.md b/AGENTS.md index ecd528a..e3d2e4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is 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 +save files to an LLM client over stdio. PCM stores databases and saves 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 @@ -56,7 +56,7 @@ All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `des | Tool | Purpose | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pcm_list_saves` | Discover `.cdb` careers by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%` (**Windows only**). | +| `pcm_list_saves` | Discover `.cdb` save files by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%` (**Windows only**). | | `pcm_validate_save` | Validate a `.cdb` path and return metadata. Stateless — the path must be kept in conversation context for later tools. | | `pcm_get_save_schema` | List all tables (id + name) in a save via `DB_STRUCTURE`. | | `pcm_get_table_schema` | Inspect one table: columns (name, type, NOT NULL, PK) + row count. | diff --git a/README.md b/README.md index 0a76309..350d5ea 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ MCP compatible

-`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. +`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 database, or generate a race startlist — all in plain language. > [!IMPORTANT] > This server never modifies your existing save files. PCM stores saves 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. diff --git a/manifest.json b/manifest.json index d13403a..29df5e6 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "pcm-mcp-server", "version": "0.2.0", "description": "MCP server for querying and editing Pro Cycling Manager game databases", - "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. This server provides tools to discover, inspect and query PCM career save files via the MCP protocol, allowing AI assistants to explore your saves in a structured way. Write tools never modify the source save: edits are always serialized to a new .cdb file, and existing files are never overwritten.", + "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. This server provides tools to discover, inspect and query PCM database files via the MCP protocol, allowing AI assistants to explore your saves in a structured way. Write tools never modify the source save: edits are always serialized to a new .cdb file, and existing files are never overwritten.", "author": { "name": "mpicciolli" }, diff --git a/src/index.ts b/src/index.ts index 86f6f6b..c5158cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ const { version } = JSON.parse( const server = new McpServer({ name: "pcm-mcp", version, + description: "Pro Cycling Manager MCP server for reading and validating .cdb database files", }); registerTools(server); diff --git a/src/saves.ts b/src/saves.ts index 222c6ec..c9fc60f 100644 --- a/src/saves.ts +++ b/src/saves.ts @@ -18,11 +18,11 @@ export interface SaveFile { const PCM_FOLDER_PREFIX = "Pro Cycling Manager"; /** - * Within each version folder, ongoing careers live under `Cloud//`, + * Within each version folder, ongoing save files live under `Cloud//`, * where `` is the player's SteamID64 or a * profile name, depending on the PCM version. * (Timestamped backups live in a sibling `WeeklySaves/` folder, which we - * intentionally do not scan — only live careers are surfaced.) + * intentionally do not scan — only live save files are surfaced.) */ const CLOUD_DIR = "Cloud"; @@ -30,10 +30,10 @@ const CLOUD_DIR = "Cloud"; * Absolute path to the roaming AppData directory that holds the per-version * `Pro Cycling Manager ` folders. * - * PCM only ships on Windows, where careers live under + * PCM only ships on Windows, where save files live under * `%APPDATA%/Pro Cycling Manager /Cloud//` (where `` is * a SteamID64 or a profile name). On macOS/Linux the - * saves live inside a Wine/Proton prefix that we can't reliably locate, so + * save files live inside a Wine/Proton prefix that we can't reliably locate, so * auto-discovery is unsupported there — pass an absolute `.cdb` path to * `pcm_validate_save` instead. * @@ -87,7 +87,7 @@ export async function findCloudDirectories( } /** - * Recursively collect `.cdb` files under `dir` (careers nest one level deep + * Recursively collect `.cdb` files under `dir` (database files nest one level deep * inside a `` folder, but we walk arbitrary depth to be safe). */ async function collectCdbFiles(dir: string): Promise { @@ -114,7 +114,7 @@ async function collectCdbFiles(dir: string): Promise { } /** - * Discover all PCM `.cdb` career save files on the local machine, newest first. + * Discover all PCM `.cdb` save files on the local machine, newest first. * * @param root - The roaming AppData directory to scan. Defaults to the * OS-specific location. diff --git a/src/tools/list-saves.ts b/src/tools/list-saves.ts index 713df4c..b0232fe 100644 --- a/src/tools/list-saves.ts +++ b/src/tools/list-saves.ts @@ -26,7 +26,7 @@ export function registerListSaves(server: McpServer): void { { title: "List PCM saves", description: - "Discover Pro Cycling Manager `.cdb` career save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA% (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first).", + "Discover Pro Cycling Manager `.cdb` save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA% (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first).", outputSchema, annotations: { readOnlyHint: true, From b629babc11c7ea40dcf5a7af96ba9f9320df5adf Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 9 Jul 2026 07:58:41 -0400 Subject: [PATCH 03/10] chore: add pre-commit hook and update lint-staged configuration --- .husky/pre-commit | 1 + .lintstagedrc.json | 5 +++-- package.json | 4 ++-- src/index.ts | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 596339b..9961650 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,5 @@ { - "*.json": ["biome check --write"], - "*.ts": ["biome check --write"] + "*.json": ["biome check --write", "biome lint --write"], + "*.ts": ["biome check --write", "biome lint --write"], + "*.js": ["biome check --write", "biome lint --write"] } diff --git a/package.json b/package.json index ecd5de0..e9de341 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", - "prepare": "husky install", "pack": "mcpb pack . dist/pcm-mcp.mcpb", "start": "node ./dist/index.js", - "start:dev": "tsup --watch --onSuccess \"node ./dist/index.js\"" + "start:dev": "tsup --watch --onSuccess \"node ./dist/index.js\"", + "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "2.4.13", diff --git a/src/index.ts b/src/index.ts index c5158cd..1586cb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,8 @@ const { version } = JSON.parse( const server = new McpServer({ name: "pcm-mcp", version, - description: "Pro Cycling Manager MCP server for reading and validating .cdb database files", + description: + "Pro Cycling Manager MCP server for reading and validating .cdb database files", }); registerTools(server); From d9ba0f2fbe8d43a4fb1aa2a37d77935e17e4c764 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 09:35:29 -0400 Subject: [PATCH 04/10] fix(package): update bin path for pcm-mcp to use relative directory --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac45dac..74de11e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "MCP server for querying Pro Cycling Manager game databases", "type": "module", "bin": { - "pcm-mcp": "./dist/index.js" + "pcm-mcp": "dist/index.js" }, "files": [ "dist" From a3e809aaca7b2a6d4ffdb10f9c5e7ca1de664b88 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 10:07:39 -0400 Subject: [PATCH 05/10] Refactor tests to use database fixtures instead of save fixtures --- .claude/skills/pcm-startlist/SKILL.md | 20 +-- AGENTS.md | 73 ++++++---- README.md | 67 ++++++--- manifest.json | 4 +- package.json | 2 +- src/{save-db.ts => cdb.ts} | 129 ++++++++++++------ src/helpers.ts | 6 +- src/saves.ts | 78 ++++------- src/schemas/cyclist.ts | 4 +- src/tools/generate-startlist-xml.ts | 12 +- src/tools/get-player-info.ts | 14 +- src/tools/get-table-schema.ts | 18 +-- src/tools/get-team-roster.ts | 20 +-- src/tools/index.ts | 16 +-- src/tools/list-saves.ts | 12 +- .../{get-save-schema.ts => list-tables.ts} | 22 +-- .../{query-save.ts => query-database.ts} | 20 +-- src/tools/search-cyclist.ts | 14 +- src/tools/search-team.ts | 12 +- src/tools/update-cyclist-ratings.ts | 28 ++-- .../{update-save.ts => update-database.ts} | 32 ++--- ...{validate-save.ts => validate-database.ts} | 24 ++-- test/{save-db.test.ts => cdb.test.ts} | 63 ++++++--- .../{save.fixture.ts => database.fixture.ts} | 9 +- test/mocks/mock-mcp-server.ts | 2 +- test/saves.test.ts | 15 +- ....test.ts.snap => list-tables.test.ts.snap} | 8 +- test/tools/get-player-info.test.ts | 6 +- test/tools/get-save-schema.test.ts | 28 ---- test/tools/get-table-schema.test.ts | 6 +- test/tools/get-team-roster.test.ts | 8 +- test/tools/list-tables.test.ts | 31 +++++ ...ry-save.test.ts => query-database.test.ts} | 26 ++-- test/tools/search-cyclist.test.ts | 10 +- test/tools/search-team.test.ts | 25 ++-- test/tools/update-cyclist-ratings.test.ts | 69 +++++----- ...e-save.test.ts => update-database.test.ts} | 66 ++++----- 37 files changed, 550 insertions(+), 449 deletions(-) rename src/{save-db.ts => cdb.ts} (52%) rename src/tools/{get-save-schema.ts => list-tables.ts} (56%) rename src/tools/{query-save.ts => query-database.ts} (81%) rename src/tools/{update-save.ts => update-database.ts} (65%) rename src/tools/{validate-save.ts => validate-database.ts} (57%) rename test/{save-db.test.ts => cdb.test.ts} (64%) rename test/fixtures/{save.fixture.ts => database.fixture.ts} (68%) rename test/tools/__snapshots__/{get-save-schema.test.ts.snap => list-tables.test.ts.snap} (99%) delete mode 100644 test/tools/get-save-schema.test.ts create mode 100644 test/tools/list-tables.test.ts rename test/tools/{query-save.test.ts => query-database.test.ts} (93%) rename test/tools/{update-save.test.ts => update-database.test.ts} (82%) diff --git a/.claude/skills/pcm-startlist/SKILL.md b/.claude/skills/pcm-startlist/SKILL.md index 708389c..5c63f83 100644 --- a/.claude/skills/pcm-startlist/SKILL.md +++ b/.claude/skills/pcm-startlist/SKILL.md @@ -4,7 +4,7 @@ description: >- Build a Pro Cycling Manager (PCM) race startlist and export it as the .xml file PCM imports. Use when the user wants to create, compose, or generate a startlist for a PCM race — picking which teams take part and which riders - each team brings. Orchestrates the read-only PCM MCP tools (pcm_query_save, + each team brings. Orchestrates the read-only PCM MCP tools (pcm_query_database, pcm_search_cyclist) to gather data and pcm_generate_startlist_xml to produce the file. Triggers on phrases like "startlist", "liste de départ", "engagés pour la course", "génère le fichier xml de la course X". @@ -34,17 +34,20 @@ returned by the tool — don't invent it. ## Workflow -### 1. Get a save path +### 1. Get a database path -Every step reads a `.cdb` save. If the user hasn't given an absolute `savePath`: +Every step reads a `.cdb` database — a career save, an official release or a +community update, it makes no difference. If the user hasn't given an absolute +`databasePath`: -- Try `pcm_list_saves` (Windows only — fails on macOS/Linux Wine/Proton prefixes). +- Try `pcm_list_saves` to find their career saves (Windows only — fails on + macOS/Linux Wine/Proton prefixes). - Otherwise ask the user for the absolute `.cdb` path. Keep it in context; the tools are stateless and need it on every call. ### 2. Identify the race (get `IDrace`) -The user names a race; resolve it to an `IDrace` with `pcm_query_save`: +The user names a race; resolve it to an `IDrace` with `pcm_query_database`: ```sql SELECT IDrace, gene_sz_race_name, gene_sz_filename @@ -88,11 +91,11 @@ before generating when there's any ambiguity. ### 5. Generate the file -Call `pcm_generate_startlist_xml` with `savePath`, `raceId`, and `teams`: +Call `pcm_generate_startlist_xml` with `databasePath`, `raceId`, and `teams`: ```json { - "savePath": "/abs/path/OfficialRelease.cdb", + "databasePath": "/abs/path/OfficialRelease.cdb", "raceId": 128, "teams": [ { "id": 34, "cyclists": [7602, 5996, 1381, 3291, 6342, 3912, 5613] }, @@ -112,6 +115,7 @@ server is read-only and does not write files, so saving is done outside it. ## Notes -- All PCM MCP tools are read-only; this workflow never modifies the save. +- All PCM MCP tools used here are read-only; this workflow never modifies the + database. - IDs, not names, go into the XML — always resolve names to `IDteam`/`IDcyclist` via the queries above before calling the tool. diff --git a/AGENTS.md b/AGENTS.md index 661ada6..4e71ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,21 +4,36 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A Model Context Protocol (MCP) server that exposes Pro Cycling Manager (PCM) game -save files to an LLM client over stdio. PCM stores databases and saves 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. +A Model Context Protocol (MCP) server that exposes Pro Cycling Manager (PCM) +databases to an LLM client over stdio. 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 file is the single source of truth and **is never modified**. Write tools +(`pcm_update_database`, `pcm_update_cyclist_ratings`) mutate the in-memory copy and +serialize it to a **new** `.cdb` via `writeCdb`, which refuses to overwrite the +source or any existing file. Any new tool must keep this never-touch-the-source +guarantee. + +### Terminology + +Two words, deliberately not interchangeable: + +- **database** — a `.cdb` file. Cyanide's binary database format, and what every + tool but one actually operates on. It may be a career save, an official release + or a community update; nothing downstream cares which. The input parameter is + `databasePath`, and the internals live in `src/cdb.ts`. +- **save** — a `.cdb` the *game itself wrote* as the player played a career, + discovered under a PCM edition's `Cloud/` folder. This meaning is confined to + `src/saves.ts` and `pcm_list_saves`. + +So `pcm_list_saves` finds the player's saves; everything else takes any database. +Do not reintroduce "save" as a synonym for `.cdb` — the test fixtures are official +releases, not saves, which is precisely the case the old naming got wrong. ## Stack - **Runtime/lang:** Node.js (ESM, `bundler` module resolution), TypeScript (strict). - **MCP:** `@modelcontextprotocol/sdk` — `McpServer` + `StdioServerTransport`. -- **Save parsing:** `cdb-converter` (`cdbToSql`) + `sql.js` (in-memory SQLite). +- **Database parsing:** `cdb-converter` (`cdbToSql`) + `sql.js` (in-memory SQLite). - **Schemas:** `zod` for tool input/output schemas. - **Build:** `tsup` → `dist/` (ESM output; `.d.ts` currently disabled). **Test:** `vitest`. **Lint/format:** `biome`. @@ -28,23 +43,23 @@ never-touch-the-source guarantee. ``` src/ index.ts # entrypoint: builds McpServer, registers tools, connects stdio - 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 + cdb.ts # everything touching a .cdb: validate a path, open it in memory, serialize an edited copy, schema/game-date introspection + saves.ts # locate the player's career saves across installed PCM editions helpers.ts # cross-cutting utilities: MCP tool responses, SQL statement parsing/errors, dates, startlist XML schemas/ cyclist.ts # shared cyclist ratings schema and its SQL read/write mappings tools/ index.ts # wires every tool onto the server list-saves.ts # pcm_list_saves - validate-save.ts # pcm_validate_save - get-save-schema.ts # pcm_get_save_schema + validate-database.ts # pcm_validate_database + list-tables.ts # pcm_list_tables get-table-schema.ts # pcm_get_table_schema get-player-info.ts # pcm_get_player_info get-team-roster.ts # pcm_get_team_roster search-cyclist.ts # pcm_search_cyclist search-team.ts # pcm_search_team - query-save.ts # pcm_query_save - update-save.ts # pcm_update_save + query-database.ts # pcm_query_database + update-database.ts # pcm_update_database update-cyclist-ratings.ts # pcm_update_cyclist_ratings generate-startlist-xml.ts # pcm_generate_startlist_xml test/ # vitest specs (test/**/*.test.ts) @@ -52,34 +67,32 @@ test/ # vitest specs (test/**/*.test.ts) ## Tools -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`, but also `destructiveHint: false`: `writeSaveDb` refuses `outputPath === savePath` and refuses to overwrite an existing file at `outputPath`, so the operation can only ever create a brand-new `.cdb` — it never destroys existing data. +All tools are prefixed with `pcm_`. Every tool but `pcm_list_saves` takes an absolute `databasePath`. Read tools carry `readOnlyHint: true` / `destructiveHint: false` annotations so clients can auto-approve them. The two write tools (`pcm_update_database`, `pcm_update_cyclist_ratings`) carry `readOnlyHint: false`, but also `destructiveHint: false`: `writeCdb` refuses `outputPath === databasePath` and refuses to overwrite an existing file at `outputPath`, so the operation can only ever create a brand-new `.cdb` — it never destroys existing data. | Tool | Purpose | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pcm_list_saves` | Discover `.cdb` save files by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%` (**Windows only**). | -| `pcm_validate_save` | Validate a `.cdb` path and return metadata. Stateless — the path must be kept in conversation context for later tools. | -| `pcm_get_save_schema` | List all tables (id + name) in a save via `DB_STRUCTURE`. | +| `pcm_list_saves` | Discover the player's career saves by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%`, across every installed edition (**Windows only**). The only tool that is about saves specifically. | +| `pcm_validate_database` | Validate a `.cdb` path and return metadata. Stateless — the path must be kept in conversation context for later tools. | +| `pcm_list_tables` | List all tables (id + name) via `DB_STRUCTURE`. | | `pcm_get_table_schema` | Inspect one table: columns (name, type, NOT NULL, PK) + row count. | | `pcm_get_player_info` | Active human player + team (joins `GAM_user` `game_i_active = 1` with `DYN_team`). | | `pcm_get_team_roster` | Team roster (defaults to active player's team). Joins `DYN_cyclist` with active `DYN_contract_cyclist` + `STA_type_rider`: name, country, age, type, overall, contract end, wage, value, plus per-terrain ratings (flat). Errors on unknown `teamId`. | | `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_query_database` | Run a single read-only `SELECT`/`WITH … SELECT`. Write/DDL rejected; results capped (default 100, max 1000). | +| `pcm_update_database` | Apply a single `INSERT`/`UPDATE`/`DELETE` and write the result to a **new** `.cdb` (`outputPath` must differ from `databasePath`). SELECT/DDL/stacked statements rejected. | | `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 50–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 - **State is in the conversation, not the server.** Tools are stateless; every - save-reading tool takes an absolute `savePath` and re-validates it via - `validateSave`. There is no "current save". -- **Use `withSaveDb` for new save-reading tools.** It centralises validate → - read → convert → run → always-close. Some existing tools (`get_save_schema`, - `get_player_info`, `query_save`) still inline this boilerplate; prefer - `withSaveDb` and consider migrating them when touched. + database-reading tool takes an absolute `databasePath` and re-validates it via + `validateCdb`. There is no "current database". +- **Use `withCdb` for new database-reading tools.** It centralises validate → + read → convert → run → always-close. - **Read-only is enforced defensively** even though the DB is in-memory — see - `assertReadOnlyQuery` in `query-save.ts` (single statement, SELECT/WITH only, + `assertReadOnlyQuery` in `query-database.ts` (single statement, SELECT/WITH only, forbidden-keyword guard). - **Guard against SQL injection** when interpolating identifiers: validate table names against `DB_STRUCTURE` before building queries (see `get_table_schema`). @@ -91,7 +104,7 @@ All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `des - **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), - `pcm_list_saves`/`getPcmRoot` throw — pass an absolute `.cdb` path to `pcm_validate_save`. + `pcm_list_saves`/`getPcmRoot` throw — pass an absolute `.cdb` path to `pcm_validate_database`. - **Logging** must go to `stderr` (`console.error`); stdout is the MCP transport. - **Country fields** must use `STA_country.gene_sz_flag` (human-readable name, e.g. `France`), never `STA_country.CONSTANT` (internal constant). Keep this consistent diff --git a/README.md b/README.md index cdf3830..b7c9dbf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Pro Cycling Manager MCP Server

- Explore your Pro Cycling Manager saves with an AI assistant — riders, teams, rosters and startlists, straight from the game's database. + Explore your Pro Cycling Manager databases with an AI assistant — cyclists, teams, rosters and startlists, straight from the game's own files.

@@ -15,17 +15,26 @@ MCP compatible

-`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 database, or generate a race startlist — all in plain language. +`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) databases. Ask about a cyclist's ratings, browse a team's roster, run SQL against the database, or generate a race startlist — all in plain language. > [!IMPORTANT] -> This server never modifies your existing save files. PCM stores saves 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. +> This server never modifies your existing files. PCM stores its data 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_database` and `pcm_update_cyclist_ratings`, serialize their changes to a **new** `.cdb` file (`outputPath`) and refuse to overwrite the input — keep your original as a backup. + +### Saves and databases + +A `.cdb` file is a database, in Cyanide's own binary format. Your career **saves** are `.cdb` databases the game writes as you play, but so are the official releases shipped with the game and the community updates you can download — and the tools here work on any of them. + +That is why one tool speaks of saves and the rest speak of databases: + +- **`pcm_list_saves`** finds *your* career saves, across every PCM edition installed on the machine. +- **Every other tool** takes a `databasePath` — the path to any `.cdb`, whether it came from `pcm_list_saves` or from somewhere else entirely. ## 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 saves on Windows, or point at any `.cdb` file directly. +- **Save discovery** — auto-detect your 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. +- **Raw SQL** — run guarded, read-only `SELECT` queries against any table. - **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 auto-approved by MCP clients; the write tools write only to a separate output file and never overwrite an existing one. @@ -35,7 +44,7 @@ ### Prerequisites - [Node.js](https://nodejs.org) 22 or later (not required for the `.mcpb` bundle install) -- A Pro Cycling Manager save (a `.cdb` file) +- A Pro Cycling Manager database (a `.cdb` file — a career save, an official release or a community update) ### Install @@ -77,34 +86,48 @@ PCM only ships on Windows, where saves live under: %APPDATA%/Pro Cycling Manager /Cloud// ``` -Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linux the saves live inside a Wine/Proton prefix that can't be reliably located — pass an absolute `.cdb` path directly to `pcm_validate_save` instead. +Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linux the saves live inside a Wine/Proton prefix that can't be reliably located — pass an absolute `.cdb` path directly to `pcm_validate_database` instead. ## Available tools -All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm_update_cyclist_ratings` is read-only, so clients like Claude Desktop can approve them automatically without a confirmation prompt. The two write tools never overwrite the source save or any existing file — they can only create a new `.cdb`. +All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes an absolute `databasePath`. Every tool except `pcm_update_database` and `pcm_update_cyclist_ratings` is read-only, so clients like Claude Desktop can approve them automatically without a confirmation prompt. The two write tools never overwrite the source or any existing file — they can only create a new `.cdb`. | Tool | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **pcm_list_saves** | Discover PCM `.cdb` save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%` (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). | -| **pcm_validate_save** | Validate that an absolute path points to an existing `.cdb` save file and return its metadata. Stateless — keep the returned path in conversation context to pass to later tools. | -| **pcm_get_save_schema** | List every table inside a `.cdb` save file, with its ID and name, plus the total table count. | -| **pcm_get_table_schema** | Inspect a single table by name. Returns its columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_get_save_schema` first to discover available table names. | -| **pcm_get_player_info** | Get the active human player and their team from a save file. Returns the player login plus team details (name, resolved division name, resolved country name, evaluation and manager). | -| **pcm_search_cyclist** | Search for a cyclist by first name and/or last name (case-insensitive partial match). Returns up to 10 matches with all ratings (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur, current ability) and the resolved country name; a `truncated` flag signals when more matches exist. `mediumMountain` and `currentAbility` are `null` on saves that pre-date those columns. | +| **pcm_list_saves** | Discover your PCM career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%`, across every installed edition (Windows only). Returns each save's absolute path — pass it as `databasePath` to the other tools — plus file name, last modified date and size (newest first). | +| **pcm_validate_database** | Validate that an absolute path points to an existing `.cdb` database and return its metadata. Stateless — keep the returned path in conversation context to pass to later tools. | +| **pcm_list_tables** | List every table inside a `.cdb` database, with its ID and name, plus the total table count. | +| **pcm_get_table_schema** | Inspect a single table by name. Returns its columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_list_tables` first to discover available table names. | +| **pcm_get_player_info** | Get the active human player and their team. Returns the player login plus team details (name, resolved division name, resolved country name, evaluation and manager). | +| **pcm_search_cyclist** | Search for a cyclist by first name and/or last name (case-insensitive partial match). Returns up to 10 matches with all ratings (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur, current ability) and the resolved country name; a `truncated` flag signals when more matches exist. `mediumMountain` and `currentAbility` are `null` on databases that pre-date those columns. | | **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; a `truncated` flag signals when more matches exist. | -| **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`; 50–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`). | +| **pcm_query_database** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | +| **pcm_update_database** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement and write the modified database to a **new** `.cdb` at `outputPath`. The source is never overwritten (`outputPath` must differ from `databasePath`); `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`; 50–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 databases 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` 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_database`). | ## 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 the write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) write their changes to a separate output `.cdb`. A typical flow is: +Tools are **stateless**: there is no "current database" held by the server. Every tool takes an absolute `databasePath`, 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 file on disk is never mutated: read tools only ever read it, and the write tools (`pcm_update_database`, `pcm_update_cyclist_ratings`) write their changes to a separate output `.cdb`. A typical flow is: + +1. `pcm_list_saves` (Windows) to find your career saves, or `pcm_validate_database` with an explicit path to point at any `.cdb`. +2. `pcm_search_cyclist`, `pcm_get_team_roster`, `pcm_query_database`, … to explore it. +3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_cyclist_ratings` / `pcm_update_database` to write an edited copy. + +## Renamed tools + +Several tools and the parameter they all take were renamed, so that "save" now means only what the game itself saved and everything else says "database". MCP clients read the tool list at connect time, so **no configuration change is needed** — restart your client and it picks up the new names. Only hand-written prompts, scripts or skills that name a tool or `savePath` explicitly need updating: + +| Before | After | +| --------------------- | ------------------------- | +| `savePath` (all tools) | `databasePath` | +| `pcm_validate_save` | `pcm_validate_database` | +| `pcm_get_save_schema` | `pcm_list_tables` | +| `pcm_query_save` | `pcm_query_database` | +| `pcm_update_save` | `pcm_update_database` | -1. `pcm_list_saves` (Windows) or `pcm_validate_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_cyclist_ratings` / `pcm_update_save` to write an edited copy of the save. +`pcm_list_saves` keeps its name — it is the one tool genuinely about your saves. All other tools are unchanged. ## Development diff --git a/manifest.json b/manifest.json index e8c4414..21c6dcf 100644 --- a/manifest.json +++ b/manifest.json @@ -2,8 +2,8 @@ "manifest_version": "0.3", "name": "pcm-mcp-server", "version": "0.3.0", - "description": "MCP server for querying and editing Pro Cycling Manager game databases", - "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. This server provides tools to discover, inspect and query PCM database files via the MCP protocol, allowing AI assistants to explore your saves in a structured way. Write tools never modify the source save: edits are always serialized to a new .cdb file, and existing files are never overwritten.", + "description": "MCP server for querying and editing Pro Cycling Manager databases", + "long_description": "MCP server for querying and editing Pro Cycling Manager databases. PCM stores its data as binary .cdb database files — your career saves, but also the official releases and community updates. This server discovers your saves, and inspects and queries any .cdb via the MCP protocol, so AI assistants can explore it in a structured way. Write tools never modify the source: edits are always serialized to a new .cdb file, and existing files are never overwritten.", "author": { "name": "mpicciolli" }, diff --git a/package.json b/package.json index 74de11e..1154cbb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pcm-mcp", "version": "0.3.0", - "description": "MCP server for querying Pro Cycling Manager game databases", + "description": "MCP server for querying Pro Cycling Manager databases", "type": "module", "bin": { "pcm-mcp": "dist/index.js" diff --git a/src/save-db.ts b/src/cdb.ts similarity index 52% rename from src/save-db.ts rename to src/cdb.ts index c468fba..644034a 100644 --- a/src/save-db.ts +++ b/src/cdb.ts @@ -1,28 +1,75 @@ import { readFile, stat, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { cdbToSql, sqlToCdb } from "cdb-converter"; import initSqlJs from "sql.js"; import { errorResponse, validResponse } from "./helpers"; -import { type SaveFile, validateSave } from "./saves"; -/** An in-memory sql.js database produced from a `.cdb` save by `cdbToSql`. */ -export type SaveDb = ReturnType; +/** + * A Pro Cycling Manager `.cdb` database file on disk. + * + * `.cdb` is Cyanide's binary database format. The same format backs both a + * player's career save (see `saves.ts`) and a standalone database such as an + * official release or a community update — every tool here accepts either. + */ +export interface CdbFile { + /** Absolute path to the `.cdb` file. */ + path: string; + /** File name, e.g. `OfficialRelease-2025.cdb`. */ + name: string; + /** Last modification time as an ISO 8601 string. */ + lastModified: string; + /** File size in bytes. */ + sizeBytes: number; +} + +/** An in-memory sql.js database decoded from a `.cdb` file by `cdbToSql`. */ +export type CdbDatabase = ReturnType; /** Smallest plausible `YYYYMMDD` value (year 1000), used to reject sentinels. */ const MIN_YMD = 10000000; /** - * Read the current in-game date from a save as a `YYYYMMDD` integer + * Validate that `databasePath` points to an existing `.cdb` file and return its + * metadata. Performs no caching and mutates no state. + * + * @throws if the path does not end in `.cdb`, does not exist, or is not a file. + */ +export async function validateCdb(databasePath: string): Promise { + if (!databasePath.toLowerCase().endsWith(".cdb")) { + throw new Error(`Not a .cdb file: ${databasePath}`); + } + + let info: Awaited>; + try { + info = await stat(databasePath); + } catch { + throw new Error(`Database file not found: ${databasePath}`); + } + + if (!info.isFile()) { + throw new Error(`Path is not a file: ${databasePath}`); + } + + return { + path: databasePath, + name: basename(databasePath), + lastModified: info.mtime.toISOString(), + sizeBytes: info.size, + }; +} + +/** + * Read the current in-game date from a database as a `YYYYMMDD` integer * (e.g. `20260605`), or `null` when it can't be found or isn't a real date. * * PCM stores the career's current date in `GAM_config.gene_i_date`. It is the - * reference point for any age- or season-relative computation, since the - * on-disk save advances as the career is played. Fresh official releases that - * haven't started a career store `0` here; that sentinel is treated as "unknown" + * reference point for any age- or season-relative computation, since a career + * save advances as the career is played. Fresh official releases that haven't + * started a career store `0` here; that sentinel is treated as "unknown" * (returns `null`) so callers don't derive nonsensical ages from it. */ -export function getGameDate(db: SaveDb): number | null { +export function getGameDate(db: CdbDatabase): number | null { try { const result = db.exec("SELECT gene_i_date FROM GAM_config LIMIT 1"); const raw = result[0]?.values?.[0]?.[0]; @@ -39,12 +86,12 @@ 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. + * Some columns are absent from databases 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, + db: CdbDatabase, tableName: string, ): Set { const columnInfo = db.exec( @@ -54,47 +101,47 @@ export function getTableColumnNames( } /** - * Open a Pro Cycling Manager `.cdb` save as an in-memory SQL database, run - * `fn`, and wrap the result in an MCP tool response. + * Open a Pro Cycling Manager `.cdb` database in memory, run `fn`, and wrap the + * result in an MCP tool response. * - * Centralises the boilerplate every save-reading tool needs: - * - validates that `savePath` points to an existing `.cdb` file - * (via {@link validateSave}), - * - re-reads and converts the save on every call with `cdbToSql`, so the data - * is never stale — the on-disk save is the single source of truth, + * Centralises the boilerplate every database-reading tool needs: + * - validates that `databasePath` points to an existing `.cdb` file + * (via {@link validateCdb}), + * - re-reads and decodes the file on every call with `cdbToSql`, so the data + * is never stale — the on-disk `.cdb` is the single source of truth, * - guarantees the database is closed afterwards, even on error, * - turns thrown errors into an {@link errorResponse} and the returned value * into a {@link validResponse}. * - * `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. + * `withCdb` itself never writes to `databasePath`: the on-disk source 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 writeCdb} — 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 + * @param databasePath - Absolute path to the `.cdb` file. + * @param fn - Receives the open database and the validated file metadata, and * returns the tool's structured output. */ -export async function withSaveDb>( - savePath: string, - fn: (db: SaveDb, save: SaveFile) => T | Promise, +export async function withCdb>( + databasePath: string, + fn: (db: CdbDatabase, file: CdbFile) => T | Promise, config: { queryOnly?: boolean; } = {}, ): Promise { - let db: SaveDb | undefined; + let db: CdbDatabase | undefined; try { - const save = await validateSave(savePath); + const file = await validateCdb(databasePath); const SQL = await initSqlJs(); - const cdbBuffer = await readFile(save.path); + const cdbBuffer = await readFile(file.path); db = cdbToSql(cdbBuffer, SQL); if (config.queryOnly ?? true) { db.run("PRAGMA query_only = ON;"); } - const output = await fn(db, save); + const output = await fn(db, file); return validResponse(output); } catch (error) { @@ -107,22 +154,22 @@ export async function withSaveDb>( } /** - * Serialize an edited in-memory save back to a `.cdb` file at `outputPath`. + * Serialize an edited in-memory database 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. + * Writes only ever go to a new file: this refuses to overwrite the source + * database (`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 + * @param sourcePath - Absolute path of the source database, 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, +export async function writeCdb( + db: CdbDatabase, outputPath: string, sourcePath: string, ): Promise { @@ -133,7 +180,7 @@ export async function writeSaveDb( const resolvedOutput = resolve(outputPath); if (resolvedOutput === resolve(sourcePath)) { throw new Error( - "outputPath must differ from the source save — the input .cdb is never overwritten.", + "outputPath must differ from the source database — the input .cdb is never overwritten.", ); } diff --git a/src/helpers.ts b/src/helpers.ts index 289ca9d..30fbec2 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -65,7 +65,7 @@ export function buildStartlistXml(teams: StartlistTeam[]): string { * 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. + * Shared by the read (`pcm_query_database`) and write (`pcm_update_database`) tools. */ export function explainQueryError(error: unknown): Error { const message = error instanceof Error ? error.message : String(error); @@ -73,7 +73,7 @@ export function explainQueryError(error: unknown): 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.`, + `Table "${missingTable[1]}" does not exist in this database — use pcm_list_tables to list available tables.`, ); } @@ -87,7 +87,7 @@ export function explainQueryError(error: unknown): Error { // 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.", + "This tool is read-only — the query attempted to modify the database, which is not allowed.", ); } diff --git a/src/saves.ts b/src/saves.ts index c9fc60f..c85ad9e 100644 --- a/src/saves.ts +++ b/src/saves.ts @@ -1,28 +1,26 @@ import { readdir, stat } from "node:fs/promises"; import { homedir, platform } from "node:os"; -import { basename, join } from "node:path"; +import { join } from "node:path"; +import type { CdbFile } from "./cdb"; -/** A Pro Cycling Manager `.cdb` save file discovered on disk. */ -export interface SaveFile { - /** Absolute path to the `.cdb` file. */ - path: string; - /** File name, e.g. `MyCareer.cdb`. */ - name: string; - /** Last modification time as an ISO 8601 string. */ - lastModified: string; - /** File size in bytes. */ - sizeBytes: number; -} +/** + * Discovery of the player's own career saves, per installed PCM edition. + * + * This is the only module where "save" is meant literally: a `.cdb` written by + * the game as the player plays a career. Everywhere else a `.cdb` is just a + * database (see `cdb.ts`), which may equally be an official release or a + * community update the player never saved themselves. + */ /** PCM version folders are named like `Pro Cycling Manager 2024`. */ const PCM_FOLDER_PREFIX = "Pro Cycling Manager"; /** - * Within each version folder, ongoing save files live under `Cloud//`, + * Within each version folder, ongoing saves live under `Cloud//`, * where `` is the player's SteamID64 or a * profile name, depending on the PCM version. * (Timestamped backups live in a sibling `WeeklySaves/` folder, which we - * intentionally do not scan — only live save files are surfaced.) + * intentionally do not scan — only live saves are surfaced.) */ const CLOUD_DIR = "Cloud"; @@ -30,12 +28,12 @@ const CLOUD_DIR = "Cloud"; * Absolute path to the roaming AppData directory that holds the per-version * `Pro Cycling Manager ` folders. * - * PCM only ships on Windows, where save files live under + * PCM only ships on Windows, where saves live under * `%APPDATA%/Pro Cycling Manager /Cloud//` (where `` is * a SteamID64 or a profile name). On macOS/Linux the - * save files live inside a Wine/Proton prefix that we can't reliably locate, so + * saves live inside a Wine/Proton prefix that we can't reliably locate, so * auto-discovery is unsupported there — pass an absolute `.cdb` path to - * `pcm_validate_save` instead. + * `pcm_validate_database` instead. * * @throws on non-Windows platforms. */ @@ -44,7 +42,7 @@ export function getPcmRoot(): string { throw new Error( "Pro Cycling Manager save auto-discovery is only supported on Windows. " + "On macOS/Linux the saves live inside a Wine/Proton prefix — " + - "pass an absolute .cdb path to pcm_validate_save instead.", + "pass an absolute .cdb path to pcm_validate_database instead.", ); } return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); @@ -87,11 +85,11 @@ export async function findCloudDirectories( } /** - * Recursively collect `.cdb` files under `dir` (database files nest one level deep + * Recursively collect `.cdb` files under `dir` (saves nest one level deep * inside a `` folder, but we walk arbitrary depth to be safe). */ -async function collectCdbFiles(dir: string): Promise { - const saves: SaveFile[] = []; +async function collectCdbFiles(dir: string): Promise { + const saves: CdbFile[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); @@ -114,7 +112,7 @@ async function collectCdbFiles(dir: string): Promise { } /** - * Discover all PCM `.cdb` save files on the local machine, newest first. + * Discover all PCM career saves on the local machine, newest first. * * @param root - The roaming AppData directory to scan. Defaults to the * OS-specific location. @@ -123,17 +121,17 @@ async function collectCdbFiles(dir: string): Promise { */ export async function listSaves( root: string = getPcmRoot(), -): Promise { +): Promise { if (!(await isDirectory(root))) { throw new Error( `No Pro Cycling Manager data found. Expected a "Pro Cycling Manager " folder under: ${root}. ` + "PCM may not be installed, or its saves live in a custom location — " + - "pass an absolute .cdb path to pcm_validate_save instead.", + "pass an absolute .cdb path to pcm_validate_database instead.", ); } const cloudDirs = await findCloudDirectories(root); - const saves: SaveFile[] = []; + const saves: CdbFile[] = []; for (const dir of cloudDirs) { saves.push(...(await collectCdbFiles(dir))); } @@ -141,33 +139,3 @@ export async function listSaves( saves.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); return saves; } - -/** - * Validate that `savePath` points to an existing `.cdb` file and return its - * metadata. Performs no caching and mutates no state. - * - * @throws if the path does not end in `.cdb`, does not exist, or is not a file. - */ -export async function validateSave(savePath: string): Promise { - if (!savePath.toLowerCase().endsWith(".cdb")) { - throw new Error(`Not a .cdb save file: ${savePath}`); - } - - let info: Awaited>; - try { - info = await stat(savePath); - } catch { - throw new Error(`Save file not found: ${savePath}`); - } - - if (!info.isFile()) { - throw new Error(`Path is not a file: ${savePath}`); - } - - return { - path: savePath, - name: basename(savePath), - lastModified: info.mtime.toISOString(), - sizeBytes: info.size, - }; -} diff --git a/src/schemas/cyclist.ts b/src/schemas/cyclist.ts index fc4e2c4..20773b9 100644 --- a/src/schemas/cyclist.ts +++ b/src/schemas/cyclist.ts @@ -16,7 +16,7 @@ export const ratingsSchema = z.object({ .number() .nullable() .describe( - "Medium mountain rating (charac_i_medium_mountain) — null on saves that pre-date this column", + "Medium mountain rating (charac_i_medium_mountain) — null on databases that pre-date this column", ), downhilling: z.number().describe("Downhilling rating (charac_i_downhilling)"), cobble: z.number().describe("Cobblestone rating (charac_i_cobble)"), @@ -60,7 +60,7 @@ export const ratingColumns = { 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 + * field names. `mediumMountain` falls back to `NULL` on databases that pre-date the * `charac_i_medium_mountain` column. */ export function ratingsColumns(hasMediumMountain: boolean): string { return `c.charac_i_plain AS plain, diff --git a/src/tools/generate-startlist-xml.ts b/src/tools/generate-startlist-xml.ts index ab1834b..589bcaf 100644 --- a/src/tools/generate-startlist-xml.ts +++ b/src/tools/generate-startlist-xml.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { buildStartlistXml } from "../helpers"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const outputSchema = z.object({ fileName: z @@ -18,9 +18,11 @@ export function registerGenerateStartlistXml(server: McpServer): void { { title: "Generate PCM startlist XML", description: - 'Generate a Pro Cycling Manager startlist XML document from a list of teams and their cyclist rosters. Looks up the race in the `.cdb` save by `IDrace` to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`). Returns both the file name and the XML as text. The XML is a `` root containing `` elements, each with self-closing `` children. Team and cyclist IDs map to the PCM `DYN_team.IDteam` and `DYN_cyclist.IDcyclist` columns and can be looked up with `pcm_search_cyclist` or `pcm_query_save`. The number of cyclists per team is free.', + 'Generate a Pro Cycling Manager startlist XML document from a list of teams and their cyclist rosters. Looks up the race in the `.cdb` database by `IDrace` to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`). Returns both the file name and the XML as text. The XML is a `` root containing `` elements, each with self-closing `` children. Team and cyclist IDs map to the PCM `DYN_team.IDteam` and `DYN_cyclist.IDcyclist` columns and can be looked up with `pcm_search_cyclist` or `pcm_query_database`. The number of cyclists per team is free.', inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), raceId: z .number() .int() @@ -53,8 +55,8 @@ export function registerGenerateStartlistXml(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, raceId, teams }) => - withSaveDb(savePath, (db) => { + async ({ databasePath, raceId, teams }) => + withCdb(databasePath, (db) => { const stmt = db.prepare( "SELECT gene_sz_filename FROM STA_race WHERE IDrace = :raceId", ); diff --git a/src/tools/get-player-info.ts b/src/tools/get-player-info.ts index 0a95ebc..32a6e04 100644 --- a/src/tools/get-player-info.ts +++ b/src/tools/get-player-info.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const outputSchema = z.object({ login: z.string().describe("Player login (game_sz_login)"), @@ -32,9 +32,11 @@ export function registerGetPlayerInfo(server: McpServer): void { { title: "Get PCM player info", description: - "Get the active human player and their team from a Pro Cycling Manager `.cdb` save file. Joins GAM_user (game_i_active = 1) with DYN_team, STA_division (current and next), and STA_country to return the player login plus team details (name, division name, country name, evaluation and manager).", + "Get the active human player and their team from a Pro Cycling Manager `.cdb` database. Joins GAM_user (game_i_active = 1) with DYN_team, STA_division (current and next), and STA_country to return the player login plus team details (name, division name, country name, evaluation and manager).", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), }, outputSchema, annotations: { @@ -44,8 +46,8 @@ export function registerGetPlayerInfo(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath }) => - withSaveDb(savePath, (db, save) => { + async ({ databasePath }) => + withCdb(databasePath, (db, file) => { const stmt = db.prepare( `SELECT u.game_sz_login AS login, @@ -69,7 +71,7 @@ export function registerGetPlayerInfo(server: McpServer): void { try { if (!stmt.step()) { throw new Error( - `No active player (game_i_active = 1) found in ${save.name}.`, + `No active player (game_i_active = 1) found in ${file.name}.`, ); } row = stmt.getAsObject(); diff --git a/src/tools/get-table-schema.ts b/src/tools/get-table-schema.ts index c566fbc..1a5f933 100644 --- a/src/tools/get-table-schema.ts +++ b/src/tools/get-table-schema.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const outputSchema = z.object({ name: z.string().describe("Table name"), @@ -28,13 +28,15 @@ export function registerGetTableSchema(server: McpServer): void { { title: "Get PCM table schema", description: - "Inspect a single table inside a Pro Cycling Manager `.cdb` save file by name. Returns the table's columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_get_save_schema` first to discover available table names.", + "Inspect a single table inside a Pro Cycling Manager `.cdb` database by name. Returns the table's columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_list_tables` first to discover available table names.", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), tableName: z .string() .describe( - "Name of the table to inspect, as listed by `pcm_get_save_schema`", + "Name of the table to inspect, as listed by `pcm_list_tables`", ), }, outputSchema, @@ -45,19 +47,19 @@ export function registerGetTableSchema(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, tableName }) => - withSaveDb(savePath, (db, save) => { + async ({ databasePath, tableName }) => + withCdb(databasePath, (db, file) => { // Validate the table exists (and guard against SQL injection) by // matching the name against DB_STRUCTURE before interpolating it. // DB_STRUCTURE columns aren't named, so read by position: the table - // name is the first column (see get_save_schema). + // name is the first column (see list_tables). const structure = db.exec("SELECT * FROM DB_STRUCTURE"); const knownTables = (structure[0]?.values ?? []).map((row) => String(row[0]), ); if (!knownTables.includes(tableName)) { throw new Error( - `Table "${tableName}" not found in ${save.name}. Use pcm_get_save_schema to list available tables.`, + `Table "${tableName}" not found in ${file.name}. Use pcm_list_tables to list available tables.`, ); } diff --git a/src/tools/get-team-roster.ts b/src/tools/get-team-roster.ts index 2316b2c..d0a7131 100644 --- a/src/tools/get-team-roster.ts +++ b/src/tools/get-team-roster.ts @@ -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, getTableColumnNames, withSaveDb } from "../save-db"; +import { getGameDate, getTableColumnNames, withCdb } from "../cdb"; const cyclistSchema = z.object({ id: z.number().describe("Cyclist ID (IDcyclist)"), @@ -28,7 +28,7 @@ const cyclistSchema = z.object({ .number() .nullable() .describe( - "Overall ability / note globale (value_f_current_ability) — null on saves that pre-date this column.", + "Overall ability (value_f_current_ability) — null on databases that pre-date this column.", ), contractEndYear: z .number() @@ -46,7 +46,7 @@ const cyclistSchema = z.object({ .number() .nullable() .describe( - "Market value / valeur (value_f_capital) — null on saves that pre-date this column.", + "Market value (value_f_capital) — null on databases that pre-date this column.", ), ...ratingsSchema.shape, }); @@ -64,9 +64,11 @@ export function registerGetTeamRoster(server: McpServer): void { { title: "Get PCM team roster", description: - "List the roster of a team in a Pro Cycling Manager `.cdb` save file. Defaults to the active human player's team (GAM_user.game_i_active = 1) when `teamId` is omitted. Joins DYN_cyclist with its active DYN_contract_cyclist and STA_type_rider, and for each rider returns name, country, age, rider type, overall ability (note globale), contract end year, wage, market value and all per-terrain ability ratings (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur). Ordered by overall ability, highest first.", + "List the roster of a team in a Pro Cycling Manager `.cdb` database. Defaults to the active human player's team (GAM_user.game_i_active = 1) when `teamId` is omitted. Joins DYN_cyclist with its active DYN_contract_cyclist and STA_type_rider, and for each cyclist returns name, country, age, rider type, overall ability (note globale), contract end year, wage, market value and all per-terrain ability ratings (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur). Ordered by overall ability, highest first.", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), teamId: z .number() .int() @@ -83,8 +85,8 @@ export function registerGetTeamRoster(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, teamId }) => - withSaveDb(savePath, (db, save) => { + async ({ databasePath, teamId }) => + withCdb(databasePath, (db, file) => { // Resolve the target team: explicit teamId, else the active player's team. let resolvedTeamId = teamId; if (resolvedTeamId == null) { @@ -94,7 +96,7 @@ export function registerGetTeamRoster(server: McpServer): void { const value = teamResult[0]?.values?.[0]?.[0]; if (value == null) { throw new Error( - `No active player (game_i_active = 1) found in ${save.name}; pass teamId explicitly.`, + `No active player (game_i_active = 1) found in ${file.name}; pass teamId explicitly.`, ); } resolvedTeamId = Number(value); @@ -107,7 +109,7 @@ export function registerGetTeamRoster(server: McpServer): void { teamStmt.bind({ ":teamId": resolvedTeamId }); if (!teamStmt.step()) { throw new Error( - `Team ${resolvedTeamId} not found in ${save.name}.`, + `Team ${resolvedTeamId} not found in ${file.name}.`, ); } } finally { diff --git a/src/tools/index.ts b/src/tools/index.ts index 98398b6..0577751 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,26 +1,26 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerListSaves } from "./list-saves"; -import { registerValidateSave } from "./validate-save"; -import { registerGetSaveSchema } from "./get-save-schema"; +import { registerValidateDatabase } from "./validate-database"; +import { registerListTables } from "./list-tables"; import { registerGetTableSchema } from "./get-table-schema"; import { registerGetPlayerInfo } from "./get-player-info"; import { registerGetTeamRoster } from "./get-team-roster"; -import { registerQuerySave } from "./query-save"; +import { registerQueryDatabase } from "./query-database"; import { registerUpdateCyclistRatings } from "./update-cyclist-ratings"; -import { registerUpdateSave } from "./update-save"; +import { registerUpdateDatabase } from "./update-database"; import { registerSearchCyclist } from "./search-cyclist"; import { registerGenerateStartlistXml } from "./generate-startlist-xml"; import { registerSearchTeam } from "./search-team"; export function registerTools(server: McpServer): void { registerListSaves(server); - registerValidateSave(server); - registerGetSaveSchema(server); + registerValidateDatabase(server); + registerListTables(server); registerGetTableSchema(server); registerGetPlayerInfo(server); registerGetTeamRoster(server); - registerQuerySave(server); - registerUpdateSave(server); + registerQueryDatabase(server); + registerUpdateDatabase(server); registerUpdateCyclistRatings(server); registerSearchCyclist(server); registerGenerateStartlistXml(server); diff --git a/src/tools/list-saves.ts b/src/tools/list-saves.ts index b0232fe..6bc6d59 100644 --- a/src/tools/list-saves.ts +++ b/src/tools/list-saves.ts @@ -7,7 +7,11 @@ const outputSchema = z.object({ saves: z .array( z.object({ - path: z.string().describe("Absolute path to the .cdb save file"), + path: z + .string() + .describe( + "Absolute path to the save's .cdb file — pass it as `databasePath` to the other tools", + ), name: z.string().describe("File name, e.g. `MyCareer.cdb`"), lastModified: z .string() @@ -17,16 +21,16 @@ const outputSchema = z.object({ sizeBytes: z.number().describe("File size in bytes"), }), ) - .describe("Discovered `.cdb` save files, newest first"), + .describe("Discovered career saves, newest first"), }); export function registerListSaves(server: McpServer): void { server.registerTool( "pcm_list_saves", { - title: "List PCM saves", + title: "List the player's PCM career saves", description: - "Discover Pro Cycling Manager `.cdb` save files on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA% (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first).", + "Discover the player's own Pro Cycling Manager career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA%, across every installed edition (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). A save is stored as a `.cdb` database, so its path is what the other tools take as `databasePath` — use `pcm_validate_database` instead to point at any other `.cdb` (official release, community update).", outputSchema, annotations: { readOnlyHint: true, diff --git a/src/tools/get-save-schema.ts b/src/tools/list-tables.ts similarity index 56% rename from src/tools/get-save-schema.ts rename to src/tools/list-tables.ts index 8b3bf7f..4f3a388 100644 --- a/src/tools/get-save-schema.ts +++ b/src/tools/list-tables.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const outputSchema = z.object({ tables: z @@ -10,19 +10,21 @@ const outputSchema = z.object({ name: z.string().describe("Table name"), }), ) - .describe("Tables in the .cdb save file"), - tableCount: z.number().describe("Number of tables in the .cdb save file"), + .describe("Tables in the .cdb database"), + tableCount: z.number().describe("Number of tables in the .cdb database"), }); -export function registerGetSaveSchema(server: McpServer): void { +export function registerListTables(server: McpServer): void { server.registerTool( - "pcm_get_save_schema", + "pcm_list_tables", { - title: "Get PCM save schema", + title: "List PCM database tables", description: - "List all tables in a Pro Cycling Manager `.cdb` save via DB_STRUCTURE (table id + name), plus the total table count.", + "List every table in a Pro Cycling Manager `.cdb` database via DB_STRUCTURE (table id + name), plus the total table count. Use `pcm_get_table_schema` next to inspect a table's columns.", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), }, outputSchema, annotations: { @@ -32,8 +34,8 @@ export function registerGetSaveSchema(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath }) => - withSaveDb(savePath, (db) => { + async ({ databasePath }) => + withCdb(databasePath, (db) => { const results = db.exec("SELECT * FROM DB_STRUCTURE"); const rows = results[0]?.values ?? []; diff --git a/src/tools/query-save.ts b/src/tools/query-database.ts similarity index 81% rename from src/tools/query-save.ts rename to src/tools/query-database.ts index dd9c127..2cc56d5 100644 --- a/src/tools/query-save.ts +++ b/src/tools/query-database.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { explainQueryError, parseSingleStatement } from "../helpers"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const DEFAULT_LIMIT = 100; const MAX_LIMIT = 1000; @@ -18,15 +18,17 @@ const outputSchema = z.object({ .describe("Whether the result was capped at `limit` rows"), }); -export function registerQuerySave(server: McpServer): void { +export function registerQueryDatabase(server: McpServer): void { server.registerTool( - "pcm_query_save", + "pcm_query_database", { - title: "Query PCM save (read-only)", + title: "Query PCM database (read-only)", description: - "Run a read-only SQL query against any table in a Pro Cycling Manager `.cdb` save file. Only a single SELECT (or WITH … SELECT) statement is allowed; write/DDL statements are rejected and the save is never modified. Results are capped (default 100, max 1000 rows). Use `pcm_get_save_schema` to discover table names and `pcm_get_table_schema` to inspect their columns.", + "Run a read-only SQL query against any table in a Pro Cycling Manager `.cdb` database. Only a single SELECT (or WITH … SELECT) statement is allowed; write/DDL statements are rejected and the database is never modified. Results are capped (default 100, max 1000 rows). Use `pcm_list_tables` to discover table names and `pcm_get_table_schema` to inspect their columns.", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), query: z .string() .describe( @@ -50,8 +52,8 @@ export function registerQuerySave(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, query, limit }) => - withSaveDb(savePath, (db) => { + async ({ databasePath, query, limit }) => + withCdb(databasePath, (db) => { const safeQuery = assertReadOnlyQuery(query); const effectiveLimit = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT); @@ -96,7 +98,7 @@ export function registerQuerySave(server: McpServer): void { * 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 + * former is accepted. `PRAGMA query_only = ON` (see {@link withCdb}) stays as * the engine-level backstop. */ export function assertReadOnlyQuery(rawQuery: string): string { diff --git a/src/tools/search-cyclist.ts b/src/tools/search-cyclist.ts index 1e8de56..f356f19 100644 --- a/src/tools/search-cyclist.ts +++ b/src/tools/search-cyclist.ts @@ -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 { getTableColumnNames, withSaveDb } from "../save-db"; +import { getTableColumnNames, withCdb } from "../cdb"; const cyclistSchema = z.object({ id: z.number().describe("Cyclist ID (IDcyclist)"), @@ -16,7 +16,7 @@ const cyclistSchema = z.object({ .number() .nullable() .describe( - "Current ability (value_f_current_ability) — null on saves that pre-date this column", + "Current ability (value_f_current_ability) — null on databases that pre-date this column", ), }); @@ -36,9 +36,11 @@ export function registerSearchCyclist(server: McpServer): void { "pcm_search_cyclist", { title: "Search PCM cyclist by name", - description: `Search for a cyclist in a Pro Cycling Manager \`.cdb\` save file by first name and/or last name (case-insensitive partial match). Returns up to ${MAX_RESULTS} matching cyclists with all their ratings and their country name; \`truncated\` is true when more matches exist beyond the ${MAX_RESULTS} returned.`, + description: `Search for a cyclist in a Pro Cycling Manager \`.cdb\` database by first name and/or last name (case-insensitive partial match). Returns up to ${MAX_RESULTS} matching cyclists with all their ratings and their country name; \`truncated\` is true when more matches exist beyond the ${MAX_RESULTS} returned.`, inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), firstName: z .string() .optional() @@ -60,8 +62,8 @@ export function registerSearchCyclist(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, firstName = "", lastName = "" }) => - withSaveDb(savePath, (db) => { + async ({ databasePath, firstName = "", lastName = "" }) => + withCdb(databasePath, (db) => { const columnNames = getTableColumnNames(db, "DYN_cyclist"); const hasMediumMountain = columnNames.has("charac_i_medium_mountain"); const hasCurrentAbility = columnNames.has("value_f_current_ability"); diff --git a/src/tools/search-team.ts b/src/tools/search-team.ts index eb45746..842a868 100644 --- a/src/tools/search-team.ts +++ b/src/tools/search-team.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { withSaveDb } from "../save-db"; +import { withCdb } from "../cdb"; const teamSchema = z.object({ id: z.number().describe("Team ID (IDteam)"), @@ -38,9 +38,11 @@ export function registerSearchTeam(server: McpServer): void { "pcm_search_team", { title: "Search PCM team by name", - description: `Search for a team in a Pro Cycling Manager \`.cdb\` save file by name (case-insensitive partial match against both the full name and the short name). Returns up to ${MAX_RESULTS} matching teams with their division name, country name, evaluation and general manager; \`truncated\` is true when more matches exist beyond the ${MAX_RESULTS} returned.`, + description: `Search for a team in a Pro Cycling Manager \`.cdb\` database by name (case-insensitive partial match against both the full name and the short name). Returns up to ${MAX_RESULTS} matching teams with their division name, country name, evaluation and general manager; \`truncated\` is true when more matches exist beyond the ${MAX_RESULTS} returned.`, inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), name: z .string() .describe( @@ -55,8 +57,8 @@ export function registerSearchTeam(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, name }) => - withSaveDb(savePath, (db) => { + async ({ databasePath, name }) => + withCdb(databasePath, (db) => { const stmt = db.prepare( `SELECT t.IDteam AS id, diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts index 9742a55..205e38d 100644 --- a/src/tools/update-cyclist-ratings.ts +++ b/src/tools/update-cyclist-ratings.ts @@ -7,7 +7,7 @@ import { ratingsColumns, ratingsSchema, } from "../schemas/cyclist"; -import { getTableColumnNames, withSaveDb, writeSaveDb } from "../save-db"; +import { getTableColumnNames, withCdb, writeCdb } from "../cdb"; const ratingValue = z.number().int().min(50).max(85); @@ -19,7 +19,7 @@ const newRatingsSchema = z.object({ mediumMountain: ratingValue .optional() .describe( - "New medium mountain rating (charac_i_medium_mountain) — rejected on saves that pre-date this column", + "New medium mountain rating (charac_i_medium_mountain) — rejected on databases that pre-date this column", ), downhilling: ratingValue .optional() @@ -57,7 +57,7 @@ const newRatingsSchema = z.object({ const outputSchema = z.object({ outputPath: z .string() - .describe("Absolute path of the modified .cdb save that was written"), + .describe("Absolute path of the modified .cdb database that was written"), cyclist: z .object({ id: z.number().describe("Cyclist ID (IDcyclist)"), @@ -74,15 +74,15 @@ export function registerUpdateCyclistRatings(server: McpServer): void { { title: "Update a cyclist's ratings (writes a new .cdb)", description: - "Change one or more ability ratings of a cyclist in a Pro Cycling Manager `.cdb` save and write the result to a NEW `.cdb` file. The source save is never modified: the edited database is serialized to `outputPath`, which must differ from `savePath`. Only the ratings passed in `ratings` are changed; the cyclist's full ratings after the update are returned. Use `pcm_search_cyclist` to find the cyclist's ID first.", + "Change one or more ability ratings of a cyclist in a Pro Cycling Manager `.cdb` database and write the result to a NEW `.cdb` file. The source database is never modified: the edited database is serialized to `outputPath`, which must differ from `databasePath`. Only the ratings passed in `ratings` are changed; the cyclist's full ratings after the update are returned. Use `pcm_search_cyclist` to find the cyclist's ID first.", inputSchema: { - savePath: z + databasePath: z .string() - .describe("Absolute path to the source .cdb save file"), + .describe("Absolute path to the source .cdb database file"), outputPath: z .string() .describe( - "Absolute path of the .cdb file to write the modified save to. Must differ from savePath, sit in an existing directory, and not already exist (existing files are never overwritten).", + "Absolute path of the .cdb file to write the modified database to. Must differ from databasePath, sit in an existing directory, and not already exist (existing files are never overwritten).", ), cyclistId: z .number() @@ -102,10 +102,10 @@ export function registerUpdateCyclistRatings(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, outputPath, cyclistId, ratings }) => - withSaveDb( - savePath, - async (db, save) => { + async ({ databasePath, outputPath, cyclistId, ratings }) => + withCdb( + databasePath, + async (db, file) => { const changes = Object.entries(ratings).filter( ([, value]) => value !== undefined, ) as [RatingField, number][]; @@ -120,7 +120,7 @@ export function registerUpdateCyclistRatings(server: McpServer): void { ); if (!hasMediumMountain && ratings.mediumMountain !== undefined) { throw new Error( - "This save pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", + "This database pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", ); } @@ -140,7 +140,7 @@ export function registerUpdateCyclistRatings(server: McpServer): void { check.bind([cyclistId]); if (!check.step()) { throw new Error( - `No cyclist with IDcyclist = ${cyclistId} in this save — use pcm_search_cyclist to find the right ID.`, + `No cyclist with IDcyclist = ${cyclistId} in this database — use pcm_search_cyclist to find the right ID.`, ); } } finally { @@ -171,7 +171,7 @@ export function registerUpdateCyclistRatings(server: McpServer): void { stmt.free(); } - const written = await writeSaveDb(db, outputPath, save.path); + const written = await writeCdb(db, outputPath, file.path); const output: z.infer = { outputPath: written, diff --git a/src/tools/update-save.ts b/src/tools/update-database.ts similarity index 65% rename from src/tools/update-save.ts rename to src/tools/update-database.ts index 2cd1ef6..6b3c79b 100644 --- a/src/tools/update-save.ts +++ b/src/tools/update-database.ts @@ -1,33 +1,33 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { explainQueryError, parseSingleStatement } from "../helpers"; -import { withSaveDb, writeSaveDb } from "../save-db"; +import { withCdb, writeCdb } from "../cdb"; const outputSchema = z.object({ outputPath: z .string() - .describe("Absolute path of the modified .cdb save that was written"), + .describe("Absolute path of the modified .cdb database that was written"), rowsModified: z .number() .describe("Number of rows changed by the statement (INSERT/UPDATE/DELETE)"), statement: z.string().describe("The normalized statement that was executed"), }); -export function registerUpdateSave(server: McpServer): void { +export function registerUpdateDatabase(server: McpServer): void { server.registerTool( - "pcm_update_save", + "pcm_update_database", { - title: "Update PCM save (writes a new .cdb)", + title: "Update PCM database (writes a new .cdb)", description: - "Run a single write statement (INSERT, UPDATE or DELETE) against a Pro Cycling Manager `.cdb` save and write the result to a NEW `.cdb` file. The source save is never modified: the edited database is serialized to `outputPath`, which must differ from `savePath`. Only one data-mutating statement is allowed; SELECT, schema changes (DROP/CREATE/ALTER) and stacked statements are rejected. Use `pcm_query_save` to read, and `pcm_get_save_schema`/`pcm_get_table_schema` to discover tables and columns.", + "Run a single write statement (INSERT, UPDATE or DELETE) against a Pro Cycling Manager `.cdb` database and write the result to a NEW `.cdb` file. The source database is never modified: the edited database is serialized to `outputPath`, which must differ from `databasePath`. Only one data-mutating statement is allowed; SELECT, schema changes (DROP/CREATE/ALTER) and stacked statements are rejected. Use `pcm_query_database` to read, and `pcm_list_tables`/`pcm_get_table_schema` to discover tables and columns.", inputSchema: { - savePath: z + databasePath: z .string() - .describe("Absolute path to the source .cdb save file"), + .describe("Absolute path to the source .cdb database file"), outputPath: z .string() .describe( - "Absolute path of the .cdb file to write the modified save to. Must differ from savePath, sit in an existing directory, and not already exist (existing files are never overwritten).", + "Absolute path of the .cdb file to write the modified database to. Must differ from databasePath, sit in an existing directory, and not already exist (existing files are never overwritten).", ), statement: z .string() @@ -43,10 +43,10 @@ export function registerUpdateSave(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath, outputPath, statement }) => - withSaveDb( - savePath, - async (db, save) => { + async ({ databasePath, outputPath, statement }) => + withCdb( + databasePath, + async (db, file) => { const safe = assertWriteStatement(statement); try { @@ -56,7 +56,7 @@ export function registerUpdateSave(server: McpServer): void { } const rowsModified = db.getRowsModified(); - const written = await writeSaveDb(db, outputPath, save.path); + const written = await writeCdb(db, outputPath, file.path); const output: z.infer = { outputPath: written, @@ -79,7 +79,7 @@ const WRITE_STATEMENT_TYPES = new Set(["INSERT", "UPDATE", "DELETE"]); * Parsing is delegated to {@link parseSingleStatement}, which classifies the * statement by its leaf operation. Only `INSERT`/`UPDATE`/`DELETE` are allowed * (a `WITH … DELETE` CTE counts as a `DELETE`). Everything else is rejected: - * - reads (`SELECT`, `WITH … SELECT`) — those belong to `pcm_query_save`, and + * - reads (`SELECT`, `WITH … SELECT`) — those belong to `pcm_query_database`, and * - DDL (`DROP`/`CREATE`/`ALTER`/…) and anything unknown (`PRAGMA`, `ATTACH`), * which would alter the schema and break the `sqlToCdb` round-trip (it needs * the table structure / `DB_STRUCTURE` intact to re-encode the `.cdb`). @@ -92,7 +92,7 @@ export function assertWriteStatement(rawStatement: string): string { if (!WRITE_STATEMENT_TYPES.has(statement.type)) { throw new Error( "Only a single INSERT, UPDATE or DELETE statement is allowed. " + - "Use pcm_query_save to read; schema changes (DROP/CREATE/ALTER) are not supported.", + "Use pcm_query_database to read; schema changes (DROP/CREATE/ALTER) are not supported.", ); } diff --git a/src/tools/validate-save.ts b/src/tools/validate-database.ts similarity index 57% rename from src/tools/validate-save.ts rename to src/tools/validate-database.ts index 14e890c..828eaad 100644 --- a/src/tools/validate-save.ts +++ b/src/tools/validate-database.ts @@ -1,11 +1,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { errorResponse, validResponse } from "../helpers"; -import { validateSave } from "../saves"; +import { validateCdb } from "../cdb"; const outputSchema = z.object({ - path: z.string().describe("Absolute path to the .cdb save file"), - name: z.string().describe("File name, e.g. `MyCareer.cdb`"), + path: z.string().describe("Absolute path to the .cdb database file"), + name: z.string().describe("File name, e.g. `OfficialRelease-2025.cdb`"), lastModified: z .string() .describe( @@ -14,15 +14,17 @@ const outputSchema = z.object({ sizeBytes: z.number().describe("File size in bytes"), }); -export function registerValidateSave(server: McpServer): void { +export function registerValidateDatabase(server: McpServer): void { server.registerTool( - "pcm_validate_save", + "pcm_validate_database", { - title: "Validate PCM save", + title: "Validate PCM database", description: - "Validate that an absolute path points to an existing Pro Cycling Manager `.cdb` save file and return its metadata. Stateless: nothing is stored — keep the returned path in conversation context to pass to later tools.", + "Validate that an absolute path points to an existing Pro Cycling Manager `.cdb` database and return its metadata. Stateless: nothing is stored — keep the returned path in conversation context to pass to later tools.", inputSchema: { - savePath: z.string().describe("Absolute path to the .cdb save file"), + databasePath: z + .string() + .describe("Absolute path to the .cdb database file"), }, outputSchema, annotations: { @@ -32,12 +34,12 @@ export function registerValidateSave(server: McpServer): void { openWorldHint: false, }, }, - async ({ savePath }) => { + async ({ databasePath }) => { try { - const save = await validateSave(savePath); + const file = await validateCdb(databasePath); const output: z.infer = { - ...save, + ...file, }; return validResponse(output); diff --git a/test/save-db.test.ts b/test/cdb.test.ts similarity index 64% rename from test/save-db.test.ts rename to test/cdb.test.ts index 2038959..dfcb8ed 100644 --- a/test/save-db.test.ts +++ b/test/cdb.test.ts @@ -11,9 +11,9 @@ import { type Mock, vi, } from "vitest"; -import { getTableColumnNames, withSaveDb } from "../src/save-db"; +import { getTableColumnNames, validateCdb, withCdb } from "../src/cdb"; -// withSaveDb reads a real .cdb file but the cdb->SQL conversion needs the real +// withCdb reads a real .cdb file but the cdb->SQL conversion needs the real // binary format, so we stub it out and hand back a fake in-memory database. vi.mock("cdb-converter", () => ({ cdbToSql: vi.fn() })); vi.mock("sql.js", () => ({ default: vi.fn(() => ({})) })); @@ -21,14 +21,14 @@ vi.mock("sql.js", () => ({ default: vi.fn(() => ({})) })); const cdbToSqlMock = cdbToSql as Mock; let dir: string; -let savePath: string; +let databasePath: string; /** A fake sql.js database; we only care that it gets closed and configured. */ const fakeDb = { close: vi.fn(), run: vi.fn() }; beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "pcm-save-db-")); - savePath = join(dir, "Career.cdb"); - await writeFile(savePath, "raw cdb bytes"); + dir = await mkdtemp(join(tmpdir(), "pcm-cdb-")); + databasePath = join(dir, "Career.cdb"); + await writeFile(databasePath, "raw cdb bytes"); cdbToSqlMock.mockReset(); cdbToSqlMock.mockReturnValue(fakeDb); @@ -40,32 +40,32 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); -describe("withSaveDb", () => { +describe("withCdb", () => { it("wraps the callback's output in a valid response", async () => { - const result = await withSaveDb(savePath, () => ({ riders: 42 })); + const result = await withCdb(databasePath, () => ({ riders: 42 })); expect(result.isError).toBeUndefined(); expect(result.structuredContent).toEqual({ riders: 42 }); }); - it("passes the open database and save metadata to the callback", async () => { - const fn = vi.fn((_db: unknown, _save: { name: string; path: string }) => ({ + it("passes the open database and file metadata to the callback", async () => { + const fn = vi.fn((_db: unknown, _file: { name: string; path: string }) => ({ ok: true, })); - await withSaveDb(savePath, fn); + await withCdb(databasePath, fn); - const [db, save] = fn.mock.calls[0]; + const [db, file] = fn.mock.calls[0]; expect(db).toBe(fakeDb); - expect(save.name).toBe("Career.cdb"); - expect(save.path).toBe(savePath); + expect(file.name).toBe("Career.cdb"); + expect(file.path).toBe(databasePath); }); it("puts the database in read-only mode before running the callback", async () => { const runOrder: string[] = []; fakeDb.run.mockImplementation((sql: string) => runOrder.push(sql)); - await withSaveDb(savePath, () => { + await withCdb(databasePath, () => { runOrder.push("callback"); return {}; }); @@ -75,19 +75,19 @@ describe("withSaveDb", () => { }); it("supports async callbacks", async () => { - const result = await withSaveDb(savePath, async () => ({ async: true })); + const result = await withCdb(databasePath, async () => ({ async: true })); expect(result.structuredContent).toEqual({ async: true }); }); it("closes the database after a successful call", async () => { - await withSaveDb(savePath, () => ({})); + await withCdb(databasePath, () => ({})); expect(fakeDb.close).toHaveBeenCalledTimes(1); }); it("closes the database even when the callback throws", async () => { - const result = await withSaveDb(savePath, () => { + const result = await withCdb(databasePath, () => { throw new Error("boom"); }); @@ -97,7 +97,7 @@ describe("withSaveDb", () => { }); it("returns an error response for a non-.cdb path without opening a database", async () => { - const result = await withSaveDb(join(dir, "notes.txt"), () => ({})); + const result = await withCdb(join(dir, "notes.txt"), () => ({})); expect(result.isError).toBe(true); expect(cdbToSqlMock).not.toHaveBeenCalled(); @@ -105,8 +105,31 @@ describe("withSaveDb", () => { }); }); +describe("validateCdb", () => { + it("returns metadata for an existing .cdb file", async () => { + const file = await validateCdb(databasePath); + + expect(file.name).toBe("Career.cdb"); + expect(file.path).toBe(databasePath); + expect(file.sizeBytes).toBeGreaterThan(0); + expect(file.lastModified).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("rejects a path that is not a .cdb file", async () => { + await expect(validateCdb(join(dir, "notes.txt"))).rejects.toThrow( + "Not a .cdb file", + ); + }); + + it("rejects a .cdb path that does not exist", async () => { + await expect(validateCdb(join(dir, "missing.cdb"))).rejects.toThrow( + "Database file not found", + ); + }); +}); + describe("getTableColumnNames", () => { - // sql.js is mocked at module level for the withSaveDb tests; these tests + // sql.js is mocked at module level for the withCdb tests; these tests // need a real in-memory database, so pull in the actual module. async function realDatabase() { const { default: initSqlJs } = diff --git a/test/fixtures/save.fixture.ts b/test/fixtures/database.fixture.ts similarity index 68% rename from test/fixtures/save.fixture.ts rename to test/fixtures/database.fixture.ts index 041ad35..89c076a 100644 --- a/test/fixtures/save.fixture.ts +++ b/test/fixtures/database.fixture.ts @@ -1,6 +1,13 @@ import { fileURLToPath } from "node:url"; -export const saveFixtures: [ +/** + * Real `.cdb` databases, one per PCM edition, used to exercise the tools + * against every schema variation they must tolerate. + * + * These are official releases, not career saves — which is exactly why the + * tools take a `databasePath` rather than a `savePath`. + */ +export const databaseFixtures: [ name: string, path: string, hasMediumMountain: boolean, diff --git a/test/mocks/mock-mcp-server.ts b/test/mocks/mock-mcp-server.ts index 3e7610d..4ad67ed 100644 --- a/test/mocks/mock-mcp-server.ts +++ b/test/mocks/mock-mcp-server.ts @@ -35,7 +35,7 @@ export interface MockMcpServer { * @example * const mcp = createMockMcpServer(); * registerGetTableSchema(mcp.server); - * const result = await mcp.callTool("pcm_get_table_schema", { savePath, tableName }); + * const result = await mcp.callTool("pcm_get_table_schema", { databasePath, tableName }); */ export function createMockMcpServer(): MockMcpServer { const tools: RegisteredTool[] = []; diff --git a/test/saves.test.ts b/test/saves.test.ts index c942227..ad88986 100644 --- a/test/saves.test.ts +++ b/test/saves.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { findCloudDirectories, listSaves, validateSave } from "../src/saves"; +import { findCloudDirectories, listSaves } from "../src/saves"; let root: string; @@ -68,16 +68,3 @@ describe("listSaves", () => { expect(saves[0].name).toBe("New.cdb"); }); }); - -describe("validateSave", () => { - it("returns metadata for an existing .cdb file", async () => { - const path = await writeSave("2024", "steam-id", "Career.cdb"); - - const save = await validateSave(path); - - expect(save.name).toBe("Career.cdb"); - expect(save.path).toBe(path); - expect(save.sizeBytes).toBeGreaterThan(0); - expect(save.lastModified).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); -}); diff --git a/test/tools/__snapshots__/get-save-schema.test.ts.snap b/test/tools/__snapshots__/list-tables.test.ts.snap similarity index 99% rename from test/tools/__snapshots__/get-save-schema.test.ts.snap rename to test/tools/__snapshots__/list-tables.test.ts.snap index 690e8e8..99f7506 100644 --- a/test/tools/__snapshots__/get-save-schema.test.ts.snap +++ b/test/tools/__snapshots__/list-tables.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`getSaveSchema > returns save schema for Pro cycling manager 2018 1`] = ` +exports[`listTables > returns every table for Pro cycling manager 2018 1`] = ` { "tableCount": 150, "tables": [ @@ -608,7 +608,7 @@ exports[`getSaveSchema > returns save schema for Pro cycling manager 2018 1`] = } `; -exports[`getSaveSchema > returns save schema for Pro cycling manager 2019 1`] = ` +exports[`listTables > returns every table for Pro cycling manager 2019 1`] = ` { "tableCount": 158, "tables": [ @@ -1248,7 +1248,7 @@ exports[`getSaveSchema > returns save schema for Pro cycling manager 2019 1`] = } `; -exports[`getSaveSchema > returns save schema for Pro cycling manager 2021 1`] = ` +exports[`listTables > returns every table for Pro cycling manager 2021 1`] = ` { "tableCount": 147, "tables": [ @@ -1844,7 +1844,7 @@ exports[`getSaveSchema > returns save schema for Pro cycling manager 2021 1`] = } `; -exports[`getSaveSchema > returns save schema for Pro cycling manager 2025 1`] = ` +exports[`listTables > returns every table for Pro cycling manager 2025 1`] = ` { "tableCount": 149, "tables": [ diff --git a/test/tools/get-player-info.test.ts b/test/tools/get-player-info.test.ts index 1a3b12e..44f4354 100644 --- a/test/tools/get-player-info.test.ts +++ b/test/tools/get-player-info.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { registerGetPlayerInfo } from "../../src/tools/get-player-info"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -20,11 +20,11 @@ describe("getPlayerInfo", () => { // The official-release fixtures are databases, not played careers, so they // have no active human player (GAM_user.game_i_active = 1). The tool should // surface that as a graceful error rather than throwing. - it.each(saveFixtures)( + it.each(databaseFixtures)( "errors when there is no active player for %s", async (name, path) => { const result = await mcp.callTool("pcm_get_player_info", { - savePath: path, + databasePath: path, }); expect(result.isError).toBe(true); diff --git a/test/tools/get-save-schema.test.ts b/test/tools/get-save-schema.test.ts deleted file mode 100644 index 2869f34..0000000 --- a/test/tools/get-save-schema.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { registerGetSaveSchema } from "../../src/tools/get-save-schema"; -import { createMockMcpServer } from "../mocks/mock-mcp-server"; -import type { MockMcpServer } from "../mocks/mock-mcp-server"; -import { saveFixtures } from "../fixtures/save.fixture"; - -describe("getSaveSchema", () => { - let mcp: MockMcpServer; - - beforeEach(() => { - mcp = createMockMcpServer(); - registerGetSaveSchema(mcp.server); - }); - - it("registers the pcm_get_save_schema tool", () => { - expect(mcp.getTool("pcm_get_save_schema")).toBeDefined(); - expect(mcp.registerTool).toHaveBeenCalledOnce(); - }); - - it.each(saveFixtures)("returns save schema for %s", async (name, path) => { - const result = await mcp.callTool("pcm_get_save_schema", { - savePath: path, - }); - - expect(result.structuredContent).toBeDefined(); - expect(result.structuredContent).toMatchSnapshot(); - }); -}); diff --git a/test/tools/get-table-schema.test.ts b/test/tools/get-table-schema.test.ts index 00936ea..8d4eee1 100644 --- a/test/tools/get-table-schema.test.ts +++ b/test/tools/get-table-schema.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { registerGetTableSchema } from "../../src/tools/get-table-schema"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; describe("getTableSchema", () => { let mcp: MockMcpServer; @@ -17,11 +17,11 @@ describe("getTableSchema", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "returns STA_race table schema for %s", async (name, path) => { const result = await mcp.callTool("pcm_get_table_schema", { - savePath: path, + databasePath: path, tableName: "STA_race", }); diff --git a/test/tools/get-team-roster.test.ts b/test/tools/get-team-roster.test.ts index a0f94c0..462baec 100644 --- a/test/tools/get-team-roster.test.ts +++ b/test/tools/get-team-roster.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { registerGetTeamRoster } from "../../src/tools/get-team-roster"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -17,11 +17,11 @@ describe("getTeamRoster", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "returns the roster for a given team for %s", async (name, path) => { const result = await mcp.callTool("pcm_get_team_roster", { - savePath: path, + databasePath: path, teamId: 1, }); @@ -32,7 +32,7 @@ describe("getTeamRoster", () => { it("returns an error for an unknown team", async () => { const result = await mcp.callTool("pcm_get_team_roster", { - savePath: saveFixtures[0][1], + databasePath: databaseFixtures[0][1], teamId: 999999, }); diff --git a/test/tools/list-tables.test.ts b/test/tools/list-tables.test.ts new file mode 100644 index 0000000..c018e63 --- /dev/null +++ b/test/tools/list-tables.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { registerListTables } from "../../src/tools/list-tables"; +import { createMockMcpServer } from "../mocks/mock-mcp-server"; +import type { MockMcpServer } from "../mocks/mock-mcp-server"; +import { databaseFixtures } from "../fixtures/database.fixture"; + +describe("listTables", () => { + let mcp: MockMcpServer; + + beforeEach(() => { + mcp = createMockMcpServer(); + registerListTables(mcp.server); + }); + + it("registers the pcm_list_tables tool", () => { + expect(mcp.getTool("pcm_list_tables")).toBeDefined(); + expect(mcp.registerTool).toHaveBeenCalledOnce(); + }); + + it.each(databaseFixtures)( + "returns every table for %s", + async (name, path) => { + const result = await mcp.callTool("pcm_list_tables", { + databasePath: path, + }); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toMatchSnapshot(); + }, + ); +}); diff --git a/test/tools/query-save.test.ts b/test/tools/query-database.test.ts similarity index 93% rename from test/tools/query-save.test.ts rename to test/tools/query-database.test.ts index b235173..526210c 100644 --- a/test/tools/query-save.test.ts +++ b/test/tools/query-database.test.ts @@ -1,30 +1,30 @@ import { beforeEach, describe, expect, it } from "vitest"; import { assertReadOnlyQuery, - registerQuerySave, -} from "../../src/tools/query-save"; -import { saveFixtures } from "../fixtures/save.fixture"; + registerQueryDatabase, +} from "../../src/tools/query-database"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; -describe("querySave", () => { +describe("queryDatabase", () => { let mcp: MockMcpServer; beforeEach(() => { mcp = createMockMcpServer(); - registerQuerySave(mcp.server); + registerQueryDatabase(mcp.server); }); - it("registers the pcm_query_save tool", () => { - expect(mcp.getTool("pcm_query_save")).toBeDefined(); + it("registers the pcm_query_database tool", () => { + expect(mcp.getTool("pcm_query_database")).toBeDefined(); expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "runs a read-only SELECT against %s", async (_name, path) => { - const result = await mcp.callTool("pcm_query_save", { - savePath: path, + const result = await mcp.callTool("pcm_query_database", { + databasePath: path, query: "SELECT COUNT(*) AS n FROM STA_race", }); @@ -33,11 +33,11 @@ describe("querySave", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "rejects a WITH … DELETE CTE for %s", async (_name, path) => { - const result = await mcp.callTool("pcm_query_save", { - savePath: path, + const result = await mcp.callTool("pcm_query_database", { + databasePath: path, query: "WITH x AS (SELECT 1) DELETE FROM STA_race", }); diff --git a/test/tools/search-cyclist.test.ts b/test/tools/search-cyclist.test.ts index bfad8bb..ec1a28f 100644 --- a/test/tools/search-cyclist.test.ts +++ b/test/tools/search-cyclist.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { registerSearchCyclist } from "../../src/tools/search-cyclist"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -17,11 +17,11 @@ describe("searchCyclist", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "finds cyclists by last name for %s", async (name, path) => { const result = await mcp.callTool("pcm_search_cyclist", { - savePath: path, + databasePath: path, lastName: "van der", }); @@ -32,7 +32,7 @@ describe("searchCyclist", () => { it("caps results at 10 and flags truncation for a broad search", async () => { const result = await mcp.callTool("pcm_search_cyclist", { - savePath: saveFixtures[0][1], + databasePath: databaseFixtures[0][1], lastName: "a", }); @@ -46,7 +46,7 @@ describe("searchCyclist", () => { it("returns an error when neither name is provided", async () => { const result = await mcp.callTool("pcm_search_cyclist", { - savePath: saveFixtures[0][1], + databasePath: databaseFixtures[0][1], }); expect(result.isError).toBe(true); diff --git a/test/tools/search-team.test.ts b/test/tools/search-team.test.ts index 33e29a3..79a7a6b 100644 --- a/test/tools/search-team.test.ts +++ b/test/tools/search-team.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { registerSearchTeam } from "../../src/tools/search-team"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -17,19 +17,22 @@ describe("searchTeam", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)("finds teams by name for %s", async (name, path) => { - const result = await mcp.callTool("pcm_search_team", { - savePath: path, - name: "movistar", - }); + it.each(databaseFixtures)( + "finds teams by name for %s", + async (name, path) => { + const result = await mcp.callTool("pcm_search_team", { + databasePath: path, + name: "movistar", + }); - expect(result.structuredContent).toBeDefined(); - expect(result.structuredContent).toMatchSnapshot(); - }); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toMatchSnapshot(); + }, + ); it("caps results at 10 and flags truncation for a broad search", async () => { const result = await mcp.callTool("pcm_search_team", { - savePath: saveFixtures[0][1], + databasePath: databaseFixtures[0][1], name: "a", }); @@ -43,7 +46,7 @@ describe("searchTeam", () => { it("returns an error when the name is empty", async () => { const result = await mcp.callTool("pcm_search_team", { - savePath: saveFixtures[0][1], + databasePath: databaseFixtures[0][1], name: " ", }); diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index e7b5bf1..c2fada5 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -6,7 +6,7 @@ import initSqlJs from "sql.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { registerUpdateCyclistRatings } from "../../src/tools/update-cyclist-ratings"; -import { saveFixtures } from "../fixtures/save.fixture"; +import { databaseFixtures } from "../fixtures/database.fixture"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; @@ -67,14 +67,14 @@ describe("updateCyclistRatings", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "updates ratings and writes the change to a new .cdb for %s", async (_name, path) => { const cyclistId = await readFirstCyclistId(path); const outputPath = join(outDir, "edited.cdb"); const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, + databasePath: path, outputPath, cyclistId, ratings: { sprint: 81, mountain: 72 }, @@ -98,7 +98,7 @@ describe("updateCyclistRatings", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "only changes the ratings that were passed for %s", async (_name, path) => { const cyclistId = await readFirstCyclistId(path); @@ -109,7 +109,7 @@ describe("updateCyclistRatings", () => { const outputPath = join(outDir, "edited.cdb"); await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, + databasePath: path, outputPath, cyclistId, ratings: { sprint: 81 }, @@ -124,11 +124,11 @@ describe("updateCyclistRatings", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "errors on an unknown cyclist ID for %s", async (_name, path) => { const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, + databasePath: path, outputPath: join(outDir, "edited.cdb"), cyclistId: 999999999, ratings: { sprint: 81 }, @@ -137,17 +137,17 @@ describe("updateCyclistRatings", () => { expect(result.isError).toBe(true); expect(result.content[0]).toEqual({ type: "text", - text: "No cyclist with IDcyclist = 999999999 in this save — use pcm_search_cyclist to find the right ID.", + text: "No cyclist with IDcyclist = 999999999 in this database — use pcm_search_cyclist to find the right ID.", }); }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "errors when no rating is provided for %s", async (_name, path) => { const cyclistId = await readFirstCyclistId(path); const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, + databasePath: path, outputPath: join(outDir, "edited.cdb"), cyclistId, ratings: {}, @@ -161,7 +161,7 @@ describe("updateCyclistRatings", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "accepts the lowest allowed rating (50) for %s", async (_name, path) => { const cyclistId = await readFirstCyclistId(path); @@ -170,7 +170,7 @@ describe("updateCyclistRatings", () => { const result = await mcp.callTool( "pcm_update_cyclist_ratings", parseArgs({ - savePath: path, + databasePath: path, outputPath, cyclistId, ratings: { sprint: 50 }, @@ -186,7 +186,7 @@ describe("updateCyclistRatings", () => { it("rejects a rating below the minimum (49)", () => { const result = inputSchema().safeParse({ - savePath: "/saves/career.cdb", + databasePath: "/saves/career.cdb", outputPath: "/saves/edited.cdb", cyclistId: 1, ratings: { sprint: 49 }, @@ -200,31 +200,32 @@ describe("updateCyclistRatings", () => { }); }); - it.each(saveFixtures.filter(([, , hasMediumMountain]) => hasMediumMountain))( - "sets mediumMountain for %s", - async (_name, path) => { - const cyclistId = await readFirstCyclistId(path); - const outputPath = join(outDir, "edited.cdb"); - const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, - outputPath, - cyclistId, - ratings: { mediumMountain: 77 }, - }); + it.each( + databaseFixtures.filter(([, , hasMediumMountain]) => hasMediumMountain), + )("sets mediumMountain for %s", async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const outputPath = join(outDir, "edited.cdb"); + const result = await mcp.callTool("pcm_update_cyclist_ratings", { + databasePath: path, + outputPath, + cyclistId, + ratings: { mediumMountain: 77 }, + }); - expect(result.isError).toBeUndefined(); - expect( - await readRatings(outputPath, cyclistId, ["charac_i_medium_mountain"]), - ).toEqual([77]); - }, - ); + expect(result.isError).toBeUndefined(); + expect( + await readRatings(outputPath, cyclistId, ["charac_i_medium_mountain"]), + ).toEqual([77]); + }); - it.each(saveFixtures.filter(([, , hasMediumMountain]) => !hasMediumMountain))( - "rejects mediumMountain on saves that pre-date the column for %s", + it.each( + databaseFixtures.filter(([, , hasMediumMountain]) => !hasMediumMountain), + )( + "rejects mediumMountain on databases that pre-date the column for %s", async (_name, path) => { const cyclistId = await readFirstCyclistId(path); const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, + databasePath: path, outputPath: join(outDir, "edited.cdb"), cyclistId, ratings: { mediumMountain: 77 }, @@ -233,7 +234,7 @@ describe("updateCyclistRatings", () => { expect(result.isError).toBe(true); expect(result.content[0]).toEqual({ type: "text", - text: "This save pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", + text: "This database pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", }); }, ); diff --git a/test/tools/update-save.test.ts b/test/tools/update-database.test.ts similarity index 82% rename from test/tools/update-save.test.ts rename to test/tools/update-database.test.ts index f0e6f92..3fed5cd 100644 --- a/test/tools/update-save.test.ts +++ b/test/tools/update-database.test.ts @@ -6,9 +6,9 @@ import initSqlJs from "sql.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { assertWriteStatement, - registerUpdateSave, -} from "../../src/tools/update-save"; -import { saveFixtures } from "../fixtures/save.fixture"; + registerUpdateDatabase, +} from "../../src/tools/update-database"; +import { databaseFixtures } from "../fixtures/database.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -24,13 +24,13 @@ async function readGameDate(cdbPath: string): Promise { } } -describe("updateSave", () => { +describe("updateDatabase", () => { let mcp: MockMcpServer; let outDir: string; beforeEach(async () => { mcp = createMockMcpServer(); - registerUpdateSave(mcp.server); + registerUpdateDatabase(mcp.server); outDir = await mkdtemp(join(tmpdir(), "pcm-update-")); }); @@ -38,17 +38,17 @@ describe("updateSave", () => { await rm(outDir, { recursive: true, force: true }); }); - it("registers the pcm_update_save tool", () => { - expect(mcp.getTool("pcm_update_save")).toBeDefined(); + it("registers the pcm_update_database tool", () => { + expect(mcp.getTool("pcm_update_database")).toBeDefined(); expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(saveFixtures)( + it.each(databaseFixtures)( "applies an UPDATE and writes the change to a new .cdb for %s", async (_name, path) => { const outputPath = join(outDir, "edited.cdb"); - const result = await mcp.callTool("pcm_update_save", { - savePath: path, + const result = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath, statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -65,14 +65,14 @@ describe("updateSave", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "leaves the source save untouched for %s", async (_name, path) => { const before = await stat(path); const outputPath = join(outDir, "edited.cdb"); - await mcp.callTool("pcm_update_save", { - savePath: path, + await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath, statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -83,11 +83,11 @@ describe("updateSave", () => { }, ); - it.each(saveFixtures)( - "refuses to overwrite the source save for %s", + it.each(databaseFixtures)( + "refuses to overwrite the source database for %s", async (_name, path) => { - const result = await mcp.callTool("pcm_update_save", { - savePath: path, + const result = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath: path, statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -95,16 +95,16 @@ describe("updateSave", () => { expect(result.isError).toBe(true); expect(result.content[0]).toEqual({ type: "text", - text: "outputPath must differ from the source save — the input .cdb is never overwritten.", + text: "outputPath must differ from the source database — the input .cdb is never overwritten.", }); }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "rejects a non-.cdb output path for %s", async (_name, path) => { - const result = await mcp.callTool("pcm_update_save", { - savePath: path, + const result = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath: join(outDir, "edited.txt"), statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -117,21 +117,21 @@ describe("updateSave", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "refuses to overwrite an existing output file for %s", async (_name, path) => { const outputPath = join(outDir, "edited.cdb"); // First write succeeds and creates the file. - const first = await mcp.callTool("pcm_update_save", { - savePath: path, + const first = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath, statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); expect(first.isError).toBeUndefined(); // A second write to the same path must not clobber it. - const second = await mcp.callTool("pcm_update_save", { - savePath: path, + const second = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath, statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -143,11 +143,11 @@ describe("updateSave", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "errors when the output directory does not exist for %s", async (_name, path) => { - const result = await mcp.callTool("pcm_update_save", { - savePath: path, + const result = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath: join(outDir, "missing", "edited.cdb"), statement: "UPDATE GAM_config SET gene_i_date = 20991231", }); @@ -160,11 +160,11 @@ describe("updateSave", () => { }, ); - it.each(saveFixtures)( + it.each(databaseFixtures)( "maps a missing table to a schema-discovery hint for %s", async (_name, path) => { - const result = await mcp.callTool("pcm_update_save", { - savePath: path, + const result = await mcp.callTool("pcm_update_database", { + databasePath: path, outputPath: join(outDir, "edited.cdb"), statement: "UPDATE not_a_table SET x = 1", }); @@ -172,7 +172,7 @@ describe("updateSave", () => { expect(result.isError).toBe(true); expect(result.content[0]).toEqual({ type: "text", - text: 'Table "not_a_table" does not exist in this save — use pcm_get_save_schema to list available tables.', + text: 'Table "not_a_table" does not exist in this database — use pcm_list_tables to list available tables.', }); }, ); From 74ea2d02479d3abbaf9d365384e0db084a12ad80 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 10:16:07 -0400 Subject: [PATCH 06/10] fix(biome): update schema version to 2.5.6 --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index c49ac95..da4e139 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", "formatter": { "enabled": true }, From 57bf637c5ed263555a8b9dda3b5ca1ac973b95e2 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 10:20:43 -0400 Subject: [PATCH 07/10] refactor(tests): simplify parameter usage in database fixture tests --- test/tools/get-player-info.test.ts | 2 +- test/tools/get-table-schema.test.ts | 2 +- test/tools/get-team-roster.test.ts | 2 +- test/tools/list-tables.test.ts | 17 +++++++---------- test/tools/search-cyclist.test.ts | 2 +- test/tools/search-team.test.ts | 21 +++++++++------------ 6 files changed, 20 insertions(+), 26 deletions(-) diff --git a/test/tools/get-player-info.test.ts b/test/tools/get-player-info.test.ts index 44f4354..c29ad14 100644 --- a/test/tools/get-player-info.test.ts +++ b/test/tools/get-player-info.test.ts @@ -22,7 +22,7 @@ describe("getPlayerInfo", () => { // surface that as a graceful error rather than throwing. it.each(databaseFixtures)( "errors when there is no active player for %s", - async (name, path) => { + async (_, path) => { const result = await mcp.callTool("pcm_get_player_info", { databasePath: path, }); diff --git a/test/tools/get-table-schema.test.ts b/test/tools/get-table-schema.test.ts index 8d4eee1..1f34b92 100644 --- a/test/tools/get-table-schema.test.ts +++ b/test/tools/get-table-schema.test.ts @@ -19,7 +19,7 @@ describe("getTableSchema", () => { it.each(databaseFixtures)( "returns STA_race table schema for %s", - async (name, path) => { + async (_, path) => { const result = await mcp.callTool("pcm_get_table_schema", { databasePath: path, tableName: "STA_race", diff --git a/test/tools/get-team-roster.test.ts b/test/tools/get-team-roster.test.ts index 462baec..27f188c 100644 --- a/test/tools/get-team-roster.test.ts +++ b/test/tools/get-team-roster.test.ts @@ -19,7 +19,7 @@ describe("getTeamRoster", () => { it.each(databaseFixtures)( "returns the roster for a given team for %s", - async (name, path) => { + async (_, path) => { const result = await mcp.callTool("pcm_get_team_roster", { databasePath: path, teamId: 1, diff --git a/test/tools/list-tables.test.ts b/test/tools/list-tables.test.ts index c018e63..17f221c 100644 --- a/test/tools/list-tables.test.ts +++ b/test/tools/list-tables.test.ts @@ -17,15 +17,12 @@ describe("listTables", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(databaseFixtures)( - "returns every table for %s", - async (name, path) => { - const result = await mcp.callTool("pcm_list_tables", { - databasePath: path, - }); + it.each(databaseFixtures)("returns every table for %s", async (_, path) => { + const result = await mcp.callTool("pcm_list_tables", { + databasePath: path, + }); - expect(result.structuredContent).toBeDefined(); - expect(result.structuredContent).toMatchSnapshot(); - }, - ); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toMatchSnapshot(); + }); }); diff --git a/test/tools/search-cyclist.test.ts b/test/tools/search-cyclist.test.ts index ec1a28f..b6365d1 100644 --- a/test/tools/search-cyclist.test.ts +++ b/test/tools/search-cyclist.test.ts @@ -19,7 +19,7 @@ describe("searchCyclist", () => { it.each(databaseFixtures)( "finds cyclists by last name for %s", - async (name, path) => { + async (_, path) => { const result = await mcp.callTool("pcm_search_cyclist", { databasePath: path, lastName: "van der", diff --git a/test/tools/search-team.test.ts b/test/tools/search-team.test.ts index 79a7a6b..c58ecfa 100644 --- a/test/tools/search-team.test.ts +++ b/test/tools/search-team.test.ts @@ -17,18 +17,15 @@ describe("searchTeam", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - it.each(databaseFixtures)( - "finds teams by name for %s", - async (name, path) => { - const result = await mcp.callTool("pcm_search_team", { - databasePath: path, - name: "movistar", - }); - - expect(result.structuredContent).toBeDefined(); - expect(result.structuredContent).toMatchSnapshot(); - }, - ); + it.each(databaseFixtures)("finds teams by name for %s", async (_, path) => { + const result = await mcp.callTool("pcm_search_team", { + databasePath: path, + name: "movistar", + }); + + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toMatchSnapshot(); + }); it("caps results at 10 and flags truncation for a broad search", async () => { const result = await mcp.callTool("pcm_search_team", { From 8c8b1e7020a5c258c176a2b1402d7032c0455d4a Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 7 Aug 2026 08:53:19 -0400 Subject: [PATCH 08/10] fix: update terminology in manifest and package descriptions --- manifest.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 21c6dcf..a535fa3 100644 --- a/manifest.json +++ b/manifest.json @@ -2,8 +2,8 @@ "manifest_version": "0.3", "name": "pcm-mcp-server", "version": "0.3.0", - "description": "MCP server for querying and editing Pro Cycling Manager databases", - "long_description": "MCP server for querying and editing Pro Cycling Manager databases. PCM stores its data as binary .cdb database files — your career saves, but also the official releases and community updates. This server discovers your saves, and inspects and queries any .cdb via the MCP protocol, so AI assistants can explore it in a structured way. Write tools never modify the source: edits are always serialized to a new .cdb file, and existing files are never overwritten.", + "description": "MCP server for querying and editing Pro Cycling Manager game databases", + "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. PCM stores its data as binary .cdb database files. Your career saves, but also the official releases and community updates. This server discovers your saves, and inspects and queries any .cdb via the MCP protocol, so AI assistants can explore it in a structured way. Write tools never modify the source: edits are always serialized to a new .cdb file, and existing files are never overwritten.", "author": { "name": "mpicciolli" }, diff --git a/package.json b/package.json index 1154cbb..6b9b158 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pcm-mcp", "version": "0.3.0", - "description": "MCP server for querying Pro Cycling Manager databases", + "description": "MCP server for querying and editing Pro Cycling Manager game databases", "type": "module", "bin": { "pcm-mcp": "dist/index.js" From 017ec6240921e48ea36b01550c8bc7165a004ac7 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 7 Aug 2026 09:00:43 -0400 Subject: [PATCH 09/10] fix: update README to clarify terminology between saves and databases --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b7c9dbf..41c73c2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Pro Cycling Manager MCP Server

- Explore your Pro Cycling Manager databases with an AI assistant — cyclists, teams, rosters and startlists, straight from the game's own files. + Explore your Pro Cycling Manager databases with an AI assistant: cyclists, teams, rosters and startlists, straight from the game's own files.

@@ -15,43 +15,43 @@ MCP compatible

-`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) databases. Ask about a cyclist's ratings, browse a team's roster, run SQL against the database, or generate a race startlist — all in plain language. +`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) databases. Ask about a cyclist's ratings, browse a team's roster, run SQL against the database, or generate a race startlist, all in plain language. > [!IMPORTANT] -> This server never modifies your existing files. PCM stores its data 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_database` and `pcm_update_cyclist_ratings`, serialize their changes to a **new** `.cdb` file (`outputPath`) and refuse to overwrite the input — keep your original as a backup. +> This server never modifies your existing files. PCM stores its data 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_database` and `pcm_update_cyclist_ratings`, serialize their changes to a **new** `.cdb` file (`outputPath`) and refuse to overwrite the input, so keep your original as a backup. ### Saves and databases -A `.cdb` file is a database, in Cyanide's own binary format. Your career **saves** are `.cdb` databases the game writes as you play, but so are the official releases shipped with the game and the community updates you can download — and the tools here work on any of them. +A `.cdb` file is a database, in Cyanide's own binary format. Your career **saves** are `.cdb` databases the game writes as you play, but so are the official releases shipped with the game and the community updates you can download, and the tools here work on any of them. That is why one tool speaks of saves and the rest speak of databases: - **`pcm_list_saves`** finds *your* career saves, across every PCM edition installed on the machine. -- **Every other tool** takes a `databasePath` — the path to any `.cdb`, whether it came from `pcm_list_saves` or from somewhere else entirely. +- **Every other tool** takes a `databasePath`, the path to any `.cdb`, whether it came from `pcm_list_saves` or from somewhere else entirely. ## Features -- **Zero setup** — run it with a single `npx` command, or install a `.mcpb` bundle with no terminal at all. -- **Save discovery** — auto-detect your 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. -- **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 auto-approved by MCP clients; the write tools write only to a separate output file and never overwrite an existing one. +- **Zero setup**: run it with a single `npx` command, or install a `.mcpb` bundle with no terminal at all. +- **Save discovery**: auto-detect your 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. +- **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 auto-approved by MCP clients; the write tools write only to a separate output file and never overwrite an existing one. ## Getting started ### Prerequisites - [Node.js](https://nodejs.org) 22 or later (not required for the `.mcpb` bundle install) -- A Pro Cycling Manager database (a `.cdb` file — a career save, an official release or a community update) +- A Pro Cycling Manager database (a `.cdb` file: a career save, an official release or a community update) ### Install
MCP Bundle (Claude Desktop, no terminal) -Download the latest `pcm-mcp.mcpb` from the [Releases page](https://github.com/mpicciolli/pcm-mcp/releases) and open it with **Claude for macOS or Windows**. An installation dialog appears — no terminal required. +Download the latest `pcm-mcp.mcpb` from the [Releases page](https://github.com/mpicciolli/pcm-mcp/releases) and open it with **Claude for macOS or Windows**. An installation dialog appears, no terminal required. > [!NOTE] > This method does not auto-update. To get a newer version, download and re-install the latest `.mcpb` from the Releases page. @@ -86,16 +86,16 @@ PCM only ships on Windows, where saves live under: %APPDATA%/Pro Cycling Manager /Cloud// ``` -Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linux the saves live inside a Wine/Proton prefix that can't be reliably located — pass an absolute `.cdb` path directly to `pcm_validate_database` instead. +Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linux the saves live inside a Wine/Proton prefix that can't be reliably located, so pass an absolute `.cdb` path directly to `pcm_validate_database` instead. ## Available tools -All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes an absolute `databasePath`. Every tool except `pcm_update_database` and `pcm_update_cyclist_ratings` is read-only, so clients like Claude Desktop can approve them automatically without a confirmation prompt. The two write tools never overwrite the source or any existing file — they can only create a new `.cdb`. +All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes an absolute `databasePath`. Every tool except `pcm_update_database` and `pcm_update_cyclist_ratings` is read-only, so clients like Claude Desktop can approve them automatically without a confirmation prompt. The two write tools never overwrite the source or any existing file; they can only create a new `.cdb`. | Tool | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **pcm_list_saves** | Discover your PCM career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%`, across every installed edition (Windows only). Returns each save's absolute path — pass it as `databasePath` to the other tools — plus file name, last modified date and size (newest first). | -| **pcm_validate_database** | Validate that an absolute path points to an existing `.cdb` database and return its metadata. Stateless — keep the returned path in conversation context to pass to later tools. | +| **pcm_list_saves** | Discover your PCM career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%`, across every installed edition (Windows only). Returns each save's absolute path (pass it as `databasePath` to the other tools), plus file name, last modified date and size (newest first). | +| **pcm_validate_database** | Validate that an absolute path points to an existing `.cdb` database and return its metadata. Stateless: keep the returned path in conversation context to pass to later tools. | | **pcm_list_tables** | List every table inside a `.cdb` database, with its ID and name, plus the total table count. | | **pcm_get_table_schema** | Inspect a single table by name. Returns its columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_list_tables` first to discover available table names. | | **pcm_get_player_info** | Get the active human player and their team. Returns the player login plus team details (name, resolved division name, resolved country name, evaluation and manager). | @@ -104,7 +104,7 @@ All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes | **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; a `truncated` flag signals when more matches exist. | | **pcm_query_database** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | | **pcm_update_database** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement and write the modified database to a **new** `.cdb` at `outputPath`. The source is never overwritten (`outputPath` must differ from `databasePath`); `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`; 50–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 databases that pre-date that column. | +| **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`; 50–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 databases 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` 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_database`). | ## How it works @@ -117,7 +117,7 @@ Tools are **stateless**: there is no "current database" held by the server. Ever ## Renamed tools -Several tools and the parameter they all take were renamed, so that "save" now means only what the game itself saved and everything else says "database". MCP clients read the tool list at connect time, so **no configuration change is needed** — restart your client and it picks up the new names. Only hand-written prompts, scripts or skills that name a tool or `savePath` explicitly need updating: +Several tools and the parameter they all take were renamed, so that "save" now means only what the game itself saved and everything else says "database". MCP clients read the tool list at connect time, so **no configuration change is needed**: restart your client and it picks up the new names. Only hand-written prompts, scripts or skills that name a tool or `savePath` explicitly need updating: | Before | After | | --------------------- | ------------------------- | @@ -127,7 +127,7 @@ Several tools and the parameter they all take were renamed, so that "save" now m | `pcm_query_save` | `pcm_query_database` | | `pcm_update_save` | `pcm_update_database` | -`pcm_list_saves` keeps its name — it is the one tool genuinely about your saves. All other tools are unchanged. +`pcm_list_saves` keeps its name: it is the one tool genuinely about your saves. All other tools are unchanged. ## Development From fc82548c8832054841efda82131d6a10f3f1d87f Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Fri, 7 Aug 2026 17:47:45 -0400 Subject: [PATCH 10/10] fix: update terminology in documentation and code to clarify distinction between saves and databases --- .claude/skills/pcm-startlist/SKILL.md | 4 ++-- AGENTS.md | 8 ++++---- README.md | 12 ++++++------ manifest.json | 2 +- src/cdb.ts | 10 +++++----- src/saves.ts | 6 +++--- src/tools/list-saves.ts | 8 ++++---- test/fixtures/database.fixture.ts | 2 +- test/tools/get-player-info.test.ts | 2 +- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.claude/skills/pcm-startlist/SKILL.md b/.claude/skills/pcm-startlist/SKILL.md index 5c63f83..2f06ca5 100644 --- a/.claude/skills/pcm-startlist/SKILL.md +++ b/.claude/skills/pcm-startlist/SKILL.md @@ -36,11 +36,11 @@ returned by the tool — don't invent it. ### 1. Get a database path -Every step reads a `.cdb` database — a career save, an official release or a +Every step reads a `.cdb` database — a player save, an official release or a community update, it makes no difference. If the user hasn't given an absolute `databasePath`: -- Try `pcm_list_saves` to find their career saves (Windows only — fails on +- Try `pcm_list_saves` to find their saves (Windows only — fails on macOS/Linux Wine/Proton prefixes). - Otherwise ask the user for the absolute `.cdb` path. Keep it in context; the tools are stateless and need it on every call. diff --git a/AGENTS.md b/AGENTS.md index 4e71ff2..f64c0bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,10 @@ guarantee. Two words, deliberately not interchangeable: - **database** — a `.cdb` file. Cyanide's binary database format, and what every - tool but one actually operates on. It may be a career save, an official release + tool but one actually operates on. It may be a player save, an official release or a community update; nothing downstream cares which. The input parameter is `databasePath`, and the internals live in `src/cdb.ts`. -- **save** — a `.cdb` the *game itself wrote* as the player played a career, +- **save** — a `.cdb` the *game itself wrote* as the player played, discovered under a PCM edition's `Cloud/` folder. This meaning is confined to `src/saves.ts` and `pcm_list_saves`. @@ -44,7 +44,7 @@ releases, not saves, which is precisely the case the old naming got wrong. src/ index.ts # entrypoint: builds McpServer, registers tools, connects stdio cdb.ts # everything touching a .cdb: validate a path, open it in memory, serialize an edited copy, schema/game-date introspection - saves.ts # locate the player's career saves across installed PCM editions + saves.ts # locate the player's saves across installed PCM editions helpers.ts # cross-cutting utilities: MCP tool responses, SQL statement parsing/errors, dates, startlist XML schemas/ cyclist.ts # shared cyclist ratings schema and its SQL read/write mappings @@ -71,7 +71,7 @@ All tools are prefixed with `pcm_`. Every tool but `pcm_list_saves` takes an abs | Tool | Purpose | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pcm_list_saves` | Discover the player's career saves by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%`, across every installed edition (**Windows only**). The only tool that is about saves specifically. | +| `pcm_list_saves` | Discover the player's saves by scanning `Pro Cycling Manager /Cloud` under `%APPDATA%`, across every installed edition (**Windows only**). The only tool that is about saves specifically. | | `pcm_validate_database` | Validate a `.cdb` path and return metadata. Stateless — the path must be kept in conversation context for later tools. | | `pcm_list_tables` | List all tables (id + name) via `DB_STRUCTURE`. | | `pcm_get_table_schema` | Inspect one table: columns (name, type, NOT NULL, PK) + row count. | diff --git a/README.md b/README.md index 41c73c2..18277a3 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,17 @@ ### Saves and databases -A `.cdb` file is a database, in Cyanide's own binary format. Your career **saves** are `.cdb` databases the game writes as you play, but so are the official releases shipped with the game and the community updates you can download, and the tools here work on any of them. +A `.cdb` file is a database, in Cyanide's own binary format. Your **saves** are `.cdb` databases the game writes as you play, but so are the official releases shipped with the game and the community updates you can download, and the tools here work on any of them. That is why one tool speaks of saves and the rest speak of databases: -- **`pcm_list_saves`** finds *your* career saves, across every PCM edition installed on the machine. +- **`pcm_list_saves`** finds *your* saves, across every PCM edition installed on the machine. - **Every other tool** takes a `databasePath`, the path to any `.cdb`, whether it came from `pcm_list_saves` or from somewhere else entirely. ## Features - **Zero setup**: run it with a single `npx` command, or install a `.mcpb` bundle with no terminal at all. -- **Save discovery**: auto-detect your career saves on Windows, or point at any `.cdb` file directly. +- **Save discovery**: auto-detect your 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. - **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. @@ -44,7 +44,7 @@ That is why one tool speaks of saves and the rest speak of databases: ### Prerequisites - [Node.js](https://nodejs.org) 22 or later (not required for the `.mcpb` bundle install) -- A Pro Cycling Manager database (a `.cdb` file: a career save, an official release or a community update) +- A Pro Cycling Manager database (a `.cdb` file: a save, an official release or a community update) ### Install @@ -94,7 +94,7 @@ All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes | Tool | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **pcm_list_saves** | Discover your PCM career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%`, across every installed edition (Windows only). Returns each save's absolute path (pass it as `databasePath` to the other tools), plus file name, last modified date and size (newest first). | +| **pcm_list_saves** | Discover your PCM saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under `%APPDATA%`, across every installed edition (Windows only). Returns each save's absolute path (pass it as `databasePath` to the other tools), plus file name, last modified date and size (newest first). | | **pcm_validate_database** | Validate that an absolute path points to an existing `.cdb` database and return its metadata. Stateless: keep the returned path in conversation context to pass to later tools. | | **pcm_list_tables** | List every table inside a `.cdb` database, with its ID and name, plus the total table count. | | **pcm_get_table_schema** | Inspect a single table by name. Returns its columns (name, SQL type, NOT NULL and primary key flags) and its row count. Use `pcm_list_tables` first to discover available table names. | @@ -111,7 +111,7 @@ All tools are prefixed with `pcm_`, and every one except `pcm_list_saves` takes Tools are **stateless**: there is no "current database" held by the server. Every tool takes an absolute `databasePath`, 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 file on disk is never mutated: read tools only ever read it, and the write tools (`pcm_update_database`, `pcm_update_cyclist_ratings`) write their changes to a separate output `.cdb`. A typical flow is: -1. `pcm_list_saves` (Windows) to find your career saves, or `pcm_validate_database` with an explicit path to point at any `.cdb`. +1. `pcm_list_saves` (Windows) to find your saves, or `pcm_validate_database` with an explicit path to point at any `.cdb`. 2. `pcm_search_cyclist`, `pcm_get_team_roster`, `pcm_query_database`, … to explore it. 3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_cyclist_ratings` / `pcm_update_database` to write an edited copy. diff --git a/manifest.json b/manifest.json index a535fa3..a162267 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "pcm-mcp-server", "version": "0.3.0", "description": "MCP server for querying and editing Pro Cycling Manager game databases", - "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. PCM stores its data as binary .cdb database files. Your career saves, but also the official releases and community updates. This server discovers your saves, and inspects and queries any .cdb via the MCP protocol, so AI assistants can explore it in a structured way. Write tools never modify the source: edits are always serialized to a new .cdb file, and existing files are never overwritten.", + "long_description": "MCP server for querying and editing Pro Cycling Manager game databases. PCM stores its data as binary .cdb database files. Your own saves, but also the official releases and community updates. This server discovers your saves, and inspects and queries any .cdb via the MCP protocol, so AI assistants can explore it in a structured way. Write tools never modify the source: edits are always serialized to a new .cdb file, and existing files are never overwritten.", "author": { "name": "mpicciolli" }, diff --git a/src/cdb.ts b/src/cdb.ts index 644034a..a640614 100644 --- a/src/cdb.ts +++ b/src/cdb.ts @@ -9,7 +9,7 @@ import { errorResponse, validResponse } from "./helpers"; * A Pro Cycling Manager `.cdb` database file on disk. * * `.cdb` is Cyanide's binary database format. The same format backs both a - * player's career save (see `saves.ts`) and a standalone database such as an + * player's save (see `saves.ts`) and a standalone database such as an * official release or a community update — every tool here accepts either. */ export interface CdbFile { @@ -63,10 +63,10 @@ export async function validateCdb(databasePath: string): Promise { * Read the current in-game date from a database as a `YYYYMMDD` integer * (e.g. `20260605`), or `null` when it can't be found or isn't a real date. * - * PCM stores the career's current date in `GAM_config.gene_i_date`. It is the - * reference point for any age- or season-relative computation, since a career - * save advances as the career is played. Fresh official releases that haven't - * started a career store `0` here; that sentinel is treated as "unknown" + * PCM stores the current in-game date in `GAM_config.gene_i_date`. It is the + * reference point for any age- or season-relative computation, since the date + * advances as the game is played. Fresh official releases that haven't been + * played store `0` here; that sentinel is treated as "unknown" * (returns `null`) so callers don't derive nonsensical ages from it. */ export function getGameDate(db: CdbDatabase): number | null { diff --git a/src/saves.ts b/src/saves.ts index c85ad9e..9c6a848 100644 --- a/src/saves.ts +++ b/src/saves.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import type { CdbFile } from "./cdb"; /** - * Discovery of the player's own career saves, per installed PCM edition. + * Discovery of the player's own saves, per installed PCM edition. * * This is the only module where "save" is meant literally: a `.cdb` written by - * the game as the player plays a career. Everywhere else a `.cdb` is just a + * the game as the player plays. Everywhere else a `.cdb` is just a * database (see `cdb.ts`), which may equally be an official release or a * community update the player never saved themselves. */ @@ -112,7 +112,7 @@ async function collectCdbFiles(dir: string): Promise { } /** - * Discover all PCM career saves on the local machine, newest first. + * Discover all PCM saves on the local machine, newest first. * * @param root - The roaming AppData directory to scan. Defaults to the * OS-specific location. diff --git a/src/tools/list-saves.ts b/src/tools/list-saves.ts index 6bc6d59..954241f 100644 --- a/src/tools/list-saves.ts +++ b/src/tools/list-saves.ts @@ -12,7 +12,7 @@ const outputSchema = z.object({ .describe( "Absolute path to the save's .cdb file — pass it as `databasePath` to the other tools", ), - name: z.string().describe("File name, e.g. `MyCareer.cdb`"), + name: z.string().describe("File name, e.g. `MySave.cdb`"), lastModified: z .string() .describe( @@ -21,16 +21,16 @@ const outputSchema = z.object({ sizeBytes: z.number().describe("File size in bytes"), }), ) - .describe("Discovered career saves, newest first"), + .describe("Discovered saves, newest first"), }); export function registerListSaves(server: McpServer): void { server.registerTool( "pcm_list_saves", { - title: "List the player's PCM career saves", + title: "List the player's PCM saves", description: - "Discover the player's own Pro Cycling Manager career saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA%, across every installed edition (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). A save is stored as a `.cdb` database, so its path is what the other tools take as `databasePath` — use `pcm_validate_database` instead to point at any other `.cdb` (official release, community update).", + "Discover the player's own Pro Cycling Manager saves on this machine by scanning the `Pro Cycling Manager /Cloud` folders under %APPDATA%, across every installed edition (Windows only). Returns each save's absolute path, file name, last modified date and size (newest first). A save is stored as a `.cdb` database, so its path is what the other tools take as `databasePath` — use `pcm_validate_database` instead to point at any other `.cdb` (official release, community update).", outputSchema, annotations: { readOnlyHint: true, diff --git a/test/fixtures/database.fixture.ts b/test/fixtures/database.fixture.ts index 89c076a..4127612 100644 --- a/test/fixtures/database.fixture.ts +++ b/test/fixtures/database.fixture.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; * Real `.cdb` databases, one per PCM edition, used to exercise the tools * against every schema variation they must tolerate. * - * These are official releases, not career saves — which is exactly why the + * These are official releases, not player saves — which is exactly why the * tools take a `databasePath` rather than a `savePath`. */ export const databaseFixtures: [ diff --git a/test/tools/get-player-info.test.ts b/test/tools/get-player-info.test.ts index c29ad14..4c647bd 100644 --- a/test/tools/get-player-info.test.ts +++ b/test/tools/get-player-info.test.ts @@ -17,7 +17,7 @@ describe("getPlayerInfo", () => { expect(mcp.registerTool).toHaveBeenCalledOnce(); }); - // The official-release fixtures are databases, not played careers, so they + // The official-release fixtures are databases, never played, so they // have no active human player (GAM_user.game_i_active = 1). The tool should // surface that as a graceful error rather than throwing. it.each(databaseFixtures)(