From 7cb61dcef4386aaaccaa4b87fa267d200dc3b231 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 15:36:42 -0400 Subject: [PATCH 1/8] feat: implement pcm_update_cyclist_ratings tool for modifying cyclist ratings in a new .cdb file --- AGENTS.md | 28 ++- README.md | 9 +- src/schemas/cyclist.ts | 24 +++ src/tools/index.ts | 2 + src/tools/update-cyclist-ratings.ts | 179 +++++++++++++++++ test/tools/update-cyclist-ratings.test.ts | 234 ++++++++++++++++++++++ 6 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 src/tools/update-cyclist-ratings.ts create mode 100644 test/tools/update-cyclist-ratings.test.ts diff --git a/AGENTS.md b/AGENTS.md index d92daa0..74a0668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A **read-only** Model Context Protocol (MCP) server that exposes Pro Cycling Manager -(PCM) game saves to an LLM client over stdio. PCM stores careers as binary `.cdb` -files; this server discovers and inspects those saves but **never writes to or -modifies them**. Each call re-reads the `.cdb` from disk and loads it into an -in-memory sql.js (SQLite) database (via `cdb-converter`), so the on-disk save is -the single source of truth and is never mutated. Any new tool must keep this -read-only guarantee. +A Model Context Protocol (MCP) server that exposes Pro Cycling Manager (PCM) game +saves to an LLM client over stdio. PCM stores careers as binary `.cdb` files; this +server discovers and inspects those saves and **never modifies the source save**. +Each call re-reads the `.cdb` from disk and loads it into an in-memory sql.js +(SQLite) database (via `cdb-converter`), so the on-disk save is the single source +of truth. Write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) mutate the +in-memory copy and serialize it to a **new** `.cdb` via `writeSaveDb`, which +refuses to overwrite the source or any existing file. Any new tool must keep this +never-touch-the-source guarantee. ## Stack @@ -27,7 +29,7 @@ read-only guarantee. src/ index.ts # entrypoint: builds McpServer, registers tools, connects stdio saves.ts # save discovery + validation (listSaves, validateSave, getPcmRoot) - save-db.ts # withSaveDb(): open .cdb in-memory, run fn, always close db; getGameDate() + save-db.ts # withSaveDb(): open .cdb in-memory, run fn, always close db; writeSaveDb(); getGameDate() helpers.ts # validResponse / errorResponse → CallToolResult; ageFromYmd(); buildStartlistXml schemas/ cyclist.ts # shared cyclist ratings: ratingsSchema / ratingsColumns() / mapRatings() @@ -42,13 +44,15 @@ src/ search-cyclist.ts # pcm_search_cyclist search-team.ts # pcm_search_team query-save.ts # pcm_query_save + update-save.ts # pcm_update_save + update-cyclist-ratings.ts # pcm_update_cyclist_ratings generate-startlist-xml.ts # pcm_generate_startlist_xml test/ # vitest specs (test/**/*.test.ts) ``` ## Tools -All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructiveHint: false` annotations so clients can auto-approve them. +All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `destructiveHint: false` annotations so clients can auto-approve them; the two write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) carry `readOnlyHint: false` / `destructiveHint: true`. | Tool | Purpose | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -61,6 +65,8 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive | `pcm_search_cyclist` | Search cyclist by first/last name (partial, case-insensitive). | | `pcm_search_team` | Search team by name (partial, case-insensitive; matches full name and short name). | | `pcm_query_save` | Run a single read-only `SELECT`/`WITH … SELECT`. Write/DDL rejected; results capped (default 100, max 1000). | +| `pcm_update_save` | Apply a single `INSERT`/`UPDATE`/`DELETE` to a save and write the result to a **new** `.cdb` (`outputPath` must differ from `savePath`). SELECT/DDL/stacked statements rejected. | +| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 0–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 @@ -80,8 +86,8 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive - **Tool responses** go through `validResponse` / `errorResponse`; declare both `inputSchema` and `outputSchema` with zod. - **Tool annotations** — every tool must include `readOnlyHint`, `destructiveHint`, - `idempotentHint`, and `openWorldHint`. All current tools are read-only - (`readOnlyHint: true`, `destructiveHint: false`). + `idempotentHint`, and `openWorldHint`. Read tools use `readOnlyHint: true` / + `destructiveHint: false`; write tools the inverse. - **Tool naming** — all tools are prefixed with `pcm_` (e.g. `pcm_list_saves`) to avoid conflicts when used alongside other MCP servers. - **Platform:** auto-discovery is Windows-only. On macOS/Linux (Wine/Proton), diff --git a/README.md b/README.md index c202b4b..3f3a7bf 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ `pcm-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI assistants such as Claude Desktop, ChatGPT and Gemini query your [Pro Cycling Manager](https://www.cyanide-studio.com/) (PCM) game saves. Ask about a rider's ratings, browse a team's roster, run SQL against the save, or generate a race startlist — all in plain language. > [!IMPORTANT] -> This server never modifies your existing save files. PCM stores careers as binary `.cdb` files; each call re-reads the `.cdb` from disk and loads it into an **in-memory** SQLite database. Every read tool leaves the source untouched. The single write tool, `pcm_update_save`, serializes its changes to a **new** `.cdb` file (`outputPath`) and refuses to overwrite the input — keep your original save as a backup. +> This server never modifies your existing save files. PCM stores careers as binary `.cdb` files; each call re-reads the `.cdb` from disk and loads it into an **in-memory** SQLite database. Every read tool leaves the source untouched. The write tools, `pcm_update_save` and `pcm_update_cyclist_ratings`, serialize their changes to a **new** `.cdb` file (`outputPath`) and refuse to overwrite the input — keep your original save as a backup. ## Features @@ -81,7 +81,7 @@ Auto-discovery via `pcm_list_saves` is therefore **Windows only**. On macOS/Linu ## Available tools -All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` is read-only and carries `readOnlyHint: true` so clients like Claude Desktop can approve them automatically without a confirmation prompt. `pcm_update_save` is the one write tool; it never overwrites the source save. +All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm_update_cyclist_ratings` is read-only and carries `readOnlyHint: true` so clients like Claude Desktop can approve them automatically without a confirmation prompt. The write tools never overwrite the source save. | Tool | Description | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -95,15 +95,16 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` is read- | **pcm_search_team** | Search for a team by name (case-insensitive partial match against both the full name and short name). Returns up to 10 matches with the resolved division name, country name, evaluation and general manager. | | **pcm_query_save** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table in a save file. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | | **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. | +| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur; 0–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | | **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). | ## How it works -Tools are **stateless**: there is no "current save" held by the server. Every tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The source save on disk is never mutated: read tools only ever read it, and `pcm_update_save` writes its changes to a separate output `.cdb`. A typical flow is: +Tools are **stateless**: there is no "current save" held by the server. Every tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The source save on disk is never mutated: read tools only ever read it, and the write tools (`pcm_update_save`, `pcm_update_cyclist_ratings`) write their changes to a separate output `.cdb`. A typical flow is: 1. `pcm_list_saves` (Windows) or `pcm_select_save` with an explicit path to locate a save. 2. `pcm_search_cyclist`, `pcm_get_team_roster`, `pcm_query_save`, … to explore it. -3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_save` to write an edited copy of the save. +3. `pcm_generate_startlist_xml` to produce a startlist file for a race, or `pcm_update_cyclist_ratings` / `pcm_update_save` to write an edited copy of the save. ## Development diff --git a/src/schemas/cyclist.ts b/src/schemas/cyclist.ts index 755b217..fc4e2c4 100644 --- a/src/schemas/cyclist.ts +++ b/src/schemas/cyclist.ts @@ -35,6 +35,30 @@ export const ratingsSchema = z.object({ baroudeur: z.number().describe("Baroudeur rating (charac_i_baroudeur)"), }); +/** + * Maps each {@link ratingsSchema} field to its `DYN_cyclist` column. Single + * source of truth for tools that write ratings back (the read path keeps its + * own aliased fragment in {@link ratingsColumns}). + */ +export const ratingColumns = { + plain: "charac_i_plain", + mountain: "charac_i_mountain", + mediumMountain: "charac_i_medium_mountain", + downhilling: "charac_i_downhilling", + cobble: "charac_i_cobble", + timeTrial: "charac_i_timetrial", + prologue: "charac_i_prologue", + sprint: "charac_i_sprint", + acceleration: "charac_i_acceleration", + endurance: "charac_i_endurance", + resistance: "charac_i_resistance", + recuperation: "charac_i_recuperation", + hill: "charac_i_hill", + baroudeur: "charac_i_baroudeur", +} as const satisfies Record, string>; + +export type RatingField = keyof typeof ratingColumns; + /** SQL `SELECT` fragment that aliases the rating columns to {@link ratingsSchema}'s * field names. `mediumMountain` falls back to `NULL` on saves that pre-date the * `charac_i_medium_mountain` column. */ diff --git a/src/tools/index.ts b/src/tools/index.ts index ae3c7ef..8b63268 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import { registerGetTableSchema } from "./get-table-schema"; import { registerGetPlayerInfo } from "./get-player-info"; import { registerGetTeamRoster } from "./get-team-roster"; import { registerQuerySave } from "./query-save"; +import { registerUpdateCyclistRatings } from "./update-cyclist-ratings"; import { registerUpdateSave } from "./update-save"; import { registerSearchCyclist } from "./search-cyclist"; import { registerGenerateStartlistXml } from "./generate-startlist-xml"; @@ -20,6 +21,7 @@ export function registerTools(server: McpServer): void { registerGetTeamRoster(server); registerQuerySave(server); registerUpdateSave(server); + registerUpdateCyclistRatings(server); registerSearchCyclist(server); registerGenerateStartlistXml(server); registerSearchTeam(server); diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts new file mode 100644 index 0000000..a57c563 --- /dev/null +++ b/src/tools/update-cyclist-ratings.ts @@ -0,0 +1,179 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + mapRatings, + type RatingField, + ratingColumns, + ratingsColumns, + ratingsSchema, +} from "../schemas/cyclist"; +import { withSaveDb, writeSaveDb } from "../save-db"; + +const ratingValue = z.number().int().min(55).max(85); + +const newRatingsSchema = z.object({ + plain: ratingValue.optional().describe("New plain rating (charac_i_plain)"), + mountain: ratingValue + .optional() + .describe("New mountain rating (charac_i_mountain)"), + mediumMountain: ratingValue + .optional() + .describe( + "New medium mountain rating (charac_i_medium_mountain) — rejected on saves that pre-date this column", + ), + downhilling: ratingValue + .optional() + .describe("New downhilling rating (charac_i_downhilling)"), + cobble: ratingValue + .optional() + .describe("New cobblestone rating (charac_i_cobble)"), + timeTrial: ratingValue + .optional() + .describe("New time trial rating (charac_i_timetrial)"), + prologue: ratingValue + .optional() + .describe("New prologue rating (charac_i_prologue)"), + sprint: ratingValue + .optional() + .describe("New sprint rating (charac_i_sprint)"), + acceleration: ratingValue + .optional() + .describe("New acceleration rating (charac_i_acceleration)"), + endurance: ratingValue + .optional() + .describe("New endurance rating (charac_i_endurance)"), + resistance: ratingValue + .optional() + .describe("New resistance rating (charac_i_resistance)"), + recuperation: ratingValue + .optional() + .describe("New recuperation rating (charac_i_recuperation)"), + hill: ratingValue.optional().describe("New hill rating (charac_i_hill)"), + baroudeur: ratingValue + .optional() + .describe("New baroudeur rating (charac_i_baroudeur)"), +}); + +const outputSchema = z.object({ + outputPath: z + .string() + .describe("Absolute path of the modified .cdb save that was written"), + cyclist: z + .object({ + id: z.number().describe("Cyclist ID (IDcyclist)"), + firstName: z.string().describe("First name (gene_sz_firstname)"), + lastName: z.string().describe("Last name (gene_sz_lastname)"), + ...ratingsSchema.shape, + }) + .describe("The cyclist with their full ratings after the update"), +}); + +export function registerUpdateCyclistRatings(server: McpServer): void { + server.registerTool( + "pcm_update_cyclist_ratings", + { + 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.", + inputSchema: { + savePath: z + .string() + .describe("Absolute path to the source .cdb save 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).", + ), + cyclistId: z + .number() + .int() + .describe( + "ID of the cyclist to modify (DYN_cyclist.IDcyclist — find it with pcm_search_cyclist)", + ), + ratings: newRatingsSchema.describe( + "Ratings to change (55–85). Only the fields provided are updated; the others keep their current value.", + ), + }, + outputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ savePath, outputPath, cyclistId, ratings }) => + withSaveDb( + savePath, + async (db, save) => { + const changes = Object.entries(ratings).filter( + ([, value]) => value !== undefined, + ) as [RatingField, number][]; + if (changes.length === 0) { + throw new Error( + "Provide at least one rating to change in `ratings`.", + ); + } + + const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); + const columnNames = new Set( + (columnInfo[0]?.values ?? []).map((r) => String(r[1])), + ); + const hasMediumMountain = columnNames.has("charac_i_medium_mountain"); + if (!hasMediumMountain && ratings.mediumMountain !== undefined) { + throw new Error( + "This save pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", + ); + } + + // Column names come from the trusted `ratingColumns` map, never from + // input; values and the ID are bound as parameters. + const setClause = changes + .map(([field]) => `${ratingColumns[field]} = ?`) + .join(", "); + db.run(`UPDATE DYN_cyclist SET ${setClause} WHERE IDcyclist = ?`, [ + ...changes.map(([, value]) => value), + cyclistId, + ]); + + if (db.getRowsModified() === 0) { + throw new Error( + `No cyclist with IDcyclist = ${cyclistId} in this save — use pcm_search_cyclist to find the right ID.`, + ); + } + + const stmt = db.prepare( + `SELECT + c.gene_sz_firstname, + c.gene_sz_lastname, + ${ratingsColumns(hasMediumMountain)} + FROM DYN_cyclist c + WHERE c.IDcyclist = :id`, + ); + let cyclist: z.infer["cyclist"]; + try { + stmt.bind({ ":id": cyclistId }); + stmt.step(); + const row = stmt.getAsObject(); + cyclist = { + id: cyclistId, + firstName: String(row.gene_sz_firstname), + lastName: String(row.gene_sz_lastname), + ...mapRatings(row), + }; + } finally { + stmt.free(); + } + + const written = await writeSaveDb(db, outputPath, save.path); + + const output: z.infer = { + outputPath: written, + cyclist, + }; + return output; + }, + { queryOnly: false }, + ), + ); +} diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts new file mode 100644 index 0000000..91ddbfc --- /dev/null +++ b/test/tools/update-cyclist-ratings.test.ts @@ -0,0 +1,234 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cdbToSql } from "cdb-converter"; +import initSqlJs from "sql.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerUpdateCyclistRatings } from "../../src/tools/update-cyclist-ratings"; +import { saveFixtures } from "../fixtures/save.fixture"; +import { createMockMcpServer } from "../mocks/mock-mcp-server"; +import type { MockMcpServer } from "../mocks/mock-mcp-server"; + +/** Read the first cyclist ID out of a `.cdb` file. */ +async function readFirstCyclistId(cdbPath: string): Promise { + const SQL = await initSqlJs(); + const db = cdbToSql(await readFile(cdbPath), SQL); + try { + const result = db.exec( + "SELECT IDcyclist FROM DYN_cyclist ORDER BY IDcyclist LIMIT 1", + ); + return Number(result[0]?.values?.[0]?.[0]); + } finally { + db.close(); + } +} + +/** Read a cyclist's rating columns back out of a written `.cdb` file. */ +async function readRatings( + cdbPath: string, + cyclistId: number, + columns: string[], +): Promise { + const SQL = await initSqlJs(); + const db = cdbToSql(await readFile(cdbPath), SQL); + try { + const result = db.exec( + `SELECT ${columns.join(", ")} FROM DYN_cyclist WHERE IDcyclist = ${cyclistId}`, + ); + return (result[0]?.values?.[0] ?? []).map(Number); + } finally { + db.close(); + } +} + +describe("updateCyclistRatings", () => { + let mcp: MockMcpServer; + let outDir: string; + + beforeEach(async () => { + mcp = createMockMcpServer(); + registerUpdateCyclistRatings(mcp.server); + outDir = await mkdtemp(join(tmpdir(), "pcm-ratings-")); + }); + + afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("registers the pcm_update_cyclist_ratings tool", () => { + expect(mcp.getTool("pcm_update_cyclist_ratings")).toBeDefined(); + expect(mcp.registerTool).toHaveBeenCalledOnce(); + }); + + it.each( + saveFixtures, + )("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, + outputPath, + cyclistId, + ratings: { sprint: 81, mountain: 42 }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ + outputPath, + cyclist: { + id: cyclistId, + sprint: 81, + mountain: 42, + }, + }); + const { cyclist } = result.structuredContent as { + cyclist: Record; + }; + expect(typeof cyclist.firstName).toBe("string"); + expect(typeof cyclist.lastName).toBe("string"); + + // The change must actually persist in the written file. + expect( + await readRatings(outputPath, cyclistId, [ + "charac_i_sprint", + "charac_i_mountain", + ]), + ).toEqual([81, 42]); + }); + + it.each( + saveFixtures, + )("only changes the ratings that were passed for %s", async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const before = await readRatings(path, cyclistId, [ + "charac_i_plain", + "charac_i_cobble", + ]); + const outputPath = join(outDir, "edited.cdb"); + + await mcp.callTool("pcm_update_cyclist_ratings", { + savePath: path, + outputPath, + cyclistId, + ratings: { sprint: 81 }, + }); + + expect( + await readRatings(outputPath, cyclistId, [ + "charac_i_plain", + "charac_i_cobble", + ]), + ).toEqual(before); + }); + + it.each( + saveFixtures, + )("leaves the source save untouched for %s", async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const before = await stat(path); + const outputPath = join(outDir, "edited.cdb"); + + await mcp.callTool("pcm_update_cyclist_ratings", { + savePath: path, + outputPath, + cyclistId, + ratings: { sprint: 81 }, + }); + + const after = await stat(path); + expect(after.size).toBe(before.size); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it.each( + saveFixtures, + )("refuses to overwrite the source save for %s", async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const result = await mcp.callTool("pcm_update_cyclist_ratings", { + savePath: path, + outputPath: path, + cyclistId, + ratings: { sprint: 81 }, + }); + + 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.", + }); + }); + + it.each( + saveFixtures, + )("errors on an unknown cyclist ID for %s", async (_name, path) => { + const result = await mcp.callTool("pcm_update_cyclist_ratings", { + savePath: path, + outputPath: join(outDir, "edited.cdb"), + cyclistId: 999999999, + ratings: { sprint: 81 }, + }); + + 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.", + }); + }); + + it.each( + saveFixtures, + )("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, + outputPath: join(outDir, "edited.cdb"), + cyclistId, + ratings: {}, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]).toEqual({ + type: "text", + text: "Provide at least one rating to change in `ratings`.", + }); + }); + + it.each( + saveFixtures, + )("handles mediumMountain according to the save's schema for %s", async (_name, path) => { + const SQL = await initSqlJs(); + const db = cdbToSql(await readFile(path), SQL); + let hasMediumMountain: boolean; + try { + const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); + hasMediumMountain = (columnInfo[0]?.values ?? []).some( + (r) => String(r[1]) === "charac_i_medium_mountain", + ); + } finally { + db.close(); + } + + 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 }, + }); + + if (hasMediumMountain) { + expect(result.isError).toBeUndefined(); + expect( + await readRatings(outputPath, cyclistId, ["charac_i_medium_mountain"]), + ).toEqual([77]); + } else { + 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.", + }); + } + }); +}); From f3bf9f82dde093ced090f91a1ab6acdfe443a3bf Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 15:50:54 -0400 Subject: [PATCH 2/8] feat: enhance database interaction by adding getTableColumnNames function and updating related tools --- AGENTS.md | 10 +++--- src/save-db.ts | 17 +++++++++++ src/tools/get-team-roster.ts | 9 ++---- src/tools/search-cyclist.ts | 7 ++--- src/tools/update-cyclist-ratings.ts | 8 ++--- test/save-db.test.ts | 37 ++++++++++++++++++++++- test/tools/update-cyclist-ratings.test.ts | 6 ++-- 7 files changed, 68 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 74a0668..db082ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,13 +28,13 @@ never-touch-the-source guarantee. ``` src/ index.ts # entrypoint: builds McpServer, registers tools, connects stdio - saves.ts # save discovery + validation (listSaves, validateSave, getPcmRoot) - save-db.ts # withSaveDb(): open .cdb in-memory, run fn, always close db; writeSaveDb(); getGameDate() - helpers.ts # validResponse / errorResponse → CallToolResult; ageFromYmd(); buildStartlistXml + saves.ts # discover .cdb saves on disk and validate paths passed by tools + save-db.ts # everything touching the database: open a save in memory, serialize an edited copy, schema/game-date introspection + helpers.ts # cross-cutting utilities: MCP tool responses, SQL statement parsing/errors, dates, startlist XML schemas/ - cyclist.ts # shared cyclist ratings: ratingsSchema / ratingsColumns() / mapRatings() + cyclist.ts # shared cyclist ratings schema and its SQL read/write mappings tools/ - index.ts # registerTools() — wires every tool onto the server + index.ts # wires every tool onto the server list-saves.ts # pcm_list_saves select-save.ts # pcm_select_save get-save-schema.ts # pcm_get_save_schema diff --git a/src/save-db.ts b/src/save-db.ts index f2f2c58..c468fba 100644 --- a/src/save-db.ts +++ b/src/save-db.ts @@ -36,6 +36,23 @@ export function getGameDate(db: SaveDb): number | null { } } +/** + * Column names of `tableName` as a Set, via `PRAGMA table_info`. + * + * Some columns are absent on saves that pre-date them — check membership with + * `.has()` so queries stay valid across PCM versions. Returns an empty set for + * unknown tables. + */ +export function getTableColumnNames( + db: SaveDb, + tableName: string, +): Set { + const columnInfo = db.exec( + `PRAGMA table_info("${tableName.replaceAll('"', '""')}")`, + ); + return new Set((columnInfo[0]?.values ?? []).map((r) => String(r[1]))); +} + /** * Open a Pro Cycling Manager `.cdb` save as an in-memory SQL database, run * `fn`, and wrap the result in an MCP tool response. diff --git a/src/tools/get-team-roster.ts b/src/tools/get-team-roster.ts index 060f095..fee4430 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, withSaveDb } from "../save-db"; +import { getGameDate, getTableColumnNames, withSaveDb } from "../save-db"; const cyclistSchema = z.object({ id: z.number().describe("Cyclist ID (IDcyclist)"), @@ -117,12 +117,7 @@ export function registerGetTeamRoster(server: McpServer): void { // The current in-game date (YYYYMMDD) is the reference point for age. const currentYmd = getGameDate(db); - // Some columns are absent on saves that pre-date them — detect them so - // the query stays valid across PCM versions. - const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); - const columnNames = new Set( - (columnInfo[0]?.values ?? []).map((r) => String(r[1])), - ); + const columnNames = getTableColumnNames(db, "DYN_cyclist"); const hasCurrentAbility = columnNames.has("value_f_current_ability"); const hasCapital = columnNames.has("value_f_capital"); const hasMediumMountain = columnNames.has("charac_i_medium_mountain"); diff --git a/src/tools/search-cyclist.ts b/src/tools/search-cyclist.ts index 9bbf1c5..6a5653b 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 { withSaveDb } from "../save-db"; +import { getTableColumnNames, withSaveDb } from "../save-db"; const cyclistSchema = z.object({ id: z.number().describe("Cyclist ID (IDcyclist)"), @@ -56,10 +56,7 @@ export function registerSearchCyclist(server: McpServer): void { }, async ({ savePath, firstName = "", lastName = "" }) => withSaveDb(savePath, (db) => { - const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); - const columnNames = new Set( - (columnInfo[0]?.values ?? []).map((r) => String(r[1])), - ); + const columnNames = getTableColumnNames(db, "DYN_cyclist"); const hasMediumMountain = columnNames.has("charac_i_medium_mountain"); const hasCurrentAbility = columnNames.has("value_f_current_ability"); diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts index a57c563..051ee7c 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 { withSaveDb, writeSaveDb } from "../save-db"; +import { getTableColumnNames, withSaveDb, writeSaveDb } from "../save-db"; const ratingValue = z.number().int().min(55).max(85); @@ -115,11 +115,9 @@ export function registerUpdateCyclistRatings(server: McpServer): void { ); } - const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); - const columnNames = new Set( - (columnInfo[0]?.values ?? []).map((r) => String(r[1])), + const hasMediumMountain = getTableColumnNames(db, "DYN_cyclist").has( + "charac_i_medium_mountain", ); - const hasMediumMountain = columnNames.has("charac_i_medium_mountain"); if (!hasMediumMountain && ratings.mediumMountain !== undefined) { throw new Error( "This save pre-dates the charac_i_medium_mountain column — mediumMountain cannot be set on it.", diff --git a/test/save-db.test.ts b/test/save-db.test.ts index ddfdfdd..2038959 100644 --- a/test/save-db.test.ts +++ b/test/save-db.test.ts @@ -11,7 +11,7 @@ import { type Mock, vi, } from "vitest"; -import { withSaveDb } from "../src/save-db"; +import { getTableColumnNames, withSaveDb } from "../src/save-db"; // withSaveDb 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. @@ -104,3 +104,38 @@ describe("withSaveDb", () => { expect(fakeDb.close).not.toHaveBeenCalled(); }); }); + +describe("getTableColumnNames", () => { + // sql.js is mocked at module level for the withSaveDb tests; these tests + // need a real in-memory database, so pull in the actual module. + async function realDatabase() { + const { default: initSqlJs } = + await vi.importActual("sql.js"); + const SQL = await initSqlJs(); + return new SQL.Database(); + } + + it("returns the column names of a known table", async () => { + const db = await realDatabase(); + try { + db.run( + "CREATE TABLE DYN_cyclist (IDcyclist INTEGER PRIMARY KEY, charac_i_sprint INTEGER)", + ); + + expect(getTableColumnNames(db, "DYN_cyclist")).toEqual( + new Set(["IDcyclist", "charac_i_sprint"]), + ); + } finally { + db.close(); + } + }); + + it("returns an empty set for an unknown table", async () => { + const db = await realDatabase(); + try { + expect(getTableColumnNames(db, "not_a_table")).toEqual(new Set()); + } finally { + db.close(); + } + }); +}); diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index 91ddbfc..1658376 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { cdbToSql } from "cdb-converter"; import initSqlJs from "sql.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getTableColumnNames } from "../../src/save-db"; import { registerUpdateCyclistRatings } from "../../src/tools/update-cyclist-ratings"; import { saveFixtures } from "../fixtures/save.fixture"; import { createMockMcpServer } from "../mocks/mock-mcp-server"; @@ -201,9 +202,8 @@ describe("updateCyclistRatings", () => { const db = cdbToSql(await readFile(path), SQL); let hasMediumMountain: boolean; try { - const columnInfo = db.exec(`PRAGMA table_info("DYN_cyclist")`); - hasMediumMountain = (columnInfo[0]?.values ?? []).some( - (r) => String(r[1]) === "charac_i_medium_mountain", + hasMediumMountain = getTableColumnNames(db, "DYN_cyclist").has( + "charac_i_medium_mountain", ); } finally { db.close(); From 03a7b11f0e7e2135fe9a66bd1ba91efc6e33a7da Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 15:51:00 -0400 Subject: [PATCH 3/8] feat: update save fixtures structure and enhance updateCyclistRatings tests for mediumMountain handling --- test/fixtures/save.fixture.ts | 10 ++- test/tools/update-cyclist-ratings.test.ts | 100 ++++++---------------- 2 files changed, 36 insertions(+), 74 deletions(-) diff --git a/test/fixtures/save.fixture.ts b/test/fixtures/save.fixture.ts index 8e50462..041ad35 100644 --- a/test/fixtures/save.fixture.ts +++ b/test/fixtures/save.fixture.ts @@ -1,28 +1,36 @@ import { fileURLToPath } from "node:url"; -export const saveFixtures = [ +export const saveFixtures: [ + name: string, + path: string, + hasMediumMountain: boolean, +][] = [ [ "Pro cycling manager 2018", fileURLToPath( new URL("../fixtures/OfficialRelease-2018.cdb", import.meta.url), ), + false, ], [ "Pro cycling manager 2019", fileURLToPath( new URL("../fixtures/OfficialRelease-2019.cdb", import.meta.url), ), + false, ], [ "Pro cycling manager 2021", fileURLToPath( new URL("../fixtures/OfficialRelease-2021.cdb", import.meta.url), ), + false, ], [ "Pro cycling manager 2025", fileURLToPath( new URL("../fixtures/OfficialRelease-2025.cdb", import.meta.url), ), + true, ], ]; diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index 1658376..fc93a69 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -1,14 +1,13 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { cdbToSql } from "cdb-converter"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { cdbToSql } from "cdb-converter"; import initSqlJs from "sql.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getTableColumnNames } from "../../src/save-db"; import { registerUpdateCyclistRatings } from "../../src/tools/update-cyclist-ratings"; import { saveFixtures } from "../fixtures/save.fixture"; -import { createMockMcpServer } from "../mocks/mock-mcp-server"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; +import { createMockMcpServer } from "../mocks/mock-mcp-server"; /** Read the first cyclist ID out of a `.cdb` file. */ async function readFirstCyclistId(cdbPath: string): Promise { @@ -83,13 +82,6 @@ describe("updateCyclistRatings", () => { mountain: 42, }, }); - const { cyclist } = result.structuredContent as { - cyclist: Record; - }; - expect(typeof cyclist.firstName).toBe("string"); - expect(typeof cyclist.lastName).toBe("string"); - - // The change must actually persist in the written file. expect( await readRatings(outputPath, cyclistId, [ "charac_i_sprint", @@ -123,43 +115,6 @@ describe("updateCyclistRatings", () => { ).toEqual(before); }); - it.each( - saveFixtures, - )("leaves the source save untouched for %s", async (_name, path) => { - const cyclistId = await readFirstCyclistId(path); - const before = await stat(path); - const outputPath = join(outDir, "edited.cdb"); - - await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, - outputPath, - cyclistId, - ratings: { sprint: 81 }, - }); - - const after = await stat(path); - expect(after.size).toBe(before.size); - expect(after.mtimeMs).toBe(before.mtimeMs); - }); - - it.each( - saveFixtures, - )("refuses to overwrite the source save for %s", async (_name, path) => { - const cyclistId = await readFirstCyclistId(path); - const result = await mcp.callTool("pcm_update_cyclist_ratings", { - savePath: path, - outputPath: path, - cyclistId, - ratings: { sprint: 81 }, - }); - - 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.", - }); - }); - it.each( saveFixtures, )("errors on an unknown cyclist ID for %s", async (_name, path) => { @@ -196,19 +151,8 @@ describe("updateCyclistRatings", () => { }); it.each( - saveFixtures, - )("handles mediumMountain according to the save's schema for %s", async (_name, path) => { - const SQL = await initSqlJs(); - const db = cdbToSql(await readFile(path), SQL); - let hasMediumMountain: boolean; - try { - hasMediumMountain = getTableColumnNames(db, "DYN_cyclist").has( - "charac_i_medium_mountain", - ); - } finally { - db.close(); - } - + 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", { @@ -218,17 +162,27 @@ describe("updateCyclistRatings", () => { ratings: { mediumMountain: 77 }, }); - if (hasMediumMountain) { - expect(result.isError).toBeUndefined(); - expect( - await readRatings(outputPath, cyclistId, ["charac_i_medium_mountain"]), - ).toEqual([77]); - } else { - 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.", - }); - } + 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", async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const result = await mcp.callTool("pcm_update_cyclist_ratings", { + savePath: path, + outputPath: join(outDir, "edited.cdb"), + cyclistId, + ratings: { mediumMountain: 77 }, + }); + + 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.", + }); }); }); From 199b0d389e9a4ba4708dccf533709a6e1c26240b Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 15:53:55 -0400 Subject: [PATCH 4/8] refactor: remove redundant comments in registerUpdateCyclistRatings function --- src/tools/update-cyclist-ratings.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts index 051ee7c..068f694 100644 --- a/src/tools/update-cyclist-ratings.ts +++ b/src/tools/update-cyclist-ratings.ts @@ -124,8 +124,6 @@ export function registerUpdateCyclistRatings(server: McpServer): void { ); } - // Column names come from the trusted `ratingColumns` map, never from - // input; values and the ID are bound as parameters. const setClause = changes .map(([field]) => `${ratingColumns[field]} = ?`) .join(", "); From 6179fc15c1d89c9a10e52166612b211024bf95b2 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 15:58:12 -0400 Subject: [PATCH 5/8] feat: update README to clarify cyclist ratings editing and safe write tool behavior --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3f3a7bf..1578cdf 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ - **Save discovery** — auto-detect PCM career saves on Windows, or point at any `.cdb` file directly. - **Rich queries** — search cyclists and teams, inspect rosters with full per-terrain ratings, and read player info. - **Raw SQL** — run guarded, read-only `SELECT` queries against any table in the save. -- **Guarded edits** — apply a single `INSERT`/`UPDATE`/`DELETE` and write the result to a new `.cdb`, never touching the original. +- **Guarded edits** — apply a single `INSERT`/`UPDATE`/`DELETE`, or edit a cyclist's ratings directly, and write the result to a new `.cdb`, never touching the original. - **Startlist export** — generate a PCM-ready startlist XML from a set of teams and rosters. -- **Safe by design** — read tools are annotated `readOnlyHint: true` for auto-approval; the write tool writes only to a separate output file. +- **Safe by design** — read tools are annotated `readOnlyHint: true` for auto-approval; the write tools write only to a separate output file and never overwrite an existing one. ## Getting started @@ -95,7 +95,7 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm | **pcm_search_team** | Search for a team by name (case-insensitive partial match against both the full name and short name). Returns up to 10 matches with the resolved division name, country name, evaluation and general manager. | | **pcm_query_save** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table in a save file. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | | **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. | -| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur; 0–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_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, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur; 55–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | | **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). | ## How it works From c266de230afb5057ebc7c6d44691e411476a639b Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 16:06:28 -0400 Subject: [PATCH 6/8] feat: update cyclist ratings range in documentation and tests --- AGENTS.md | 2 +- test/tools/update-cyclist-ratings.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index db082ee..0fa70ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `des | `pcm_search_team` | Search team by name (partial, case-insensitive; matches full name and short name). | | `pcm_query_save` | Run a single read-only `SELECT`/`WITH … SELECT`. Write/DDL rejected; results capped (default 100, max 1000). | | `pcm_update_save` | Apply a single `INSERT`/`UPDATE`/`DELETE` to a save and write the result to a **new** `.cdb` (`outputPath` must differ from `savePath`). SELECT/DDL/stacked statements rejected. | -| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 0–85) and write the result to a **new** `.cdb`. Returns the cyclist's full ratings after the update. | +| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 55–85) and write the result to a **new** `.cdb`. Returns the cyclist's full ratings after the update. | | `pcm_generate_startlist_xml` | Build a PCM startlist XML from teams + rosters; derives the file name from `STA_race.gene_sz_filename` for the given `IDrace`. | ## Conventions diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index fc93a69..8c21dd7 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -70,7 +70,7 @@ describe("updateCyclistRatings", () => { savePath: path, outputPath, cyclistId, - ratings: { sprint: 81, mountain: 42 }, + ratings: { sprint: 81, mountain: 72 }, }); expect(result.isError).toBeUndefined(); @@ -79,7 +79,7 @@ describe("updateCyclistRatings", () => { cyclist: { id: cyclistId, sprint: 81, - mountain: 42, + mountain: 72, }, }); expect( @@ -87,7 +87,7 @@ describe("updateCyclistRatings", () => { "charac_i_sprint", "charac_i_mountain", ]), - ).toEqual([81, 42]); + ).toEqual([81, 72]); }); it.each( From 05e57ed08967e50318b15b2884d4d83df5d48a43 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 16:08:08 -0400 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/tools/update-cyclist-ratings.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts index 068f694..16aab0e 100644 --- a/src/tools/update-cyclist-ratings.ts +++ b/src/tools/update-cyclist-ratings.ts @@ -133,9 +133,19 @@ export function registerUpdateCyclistRatings(server: McpServer): void { ]); if (db.getRowsModified() === 0) { - throw new Error( - `No cyclist with IDcyclist = ${cyclistId} in this save — use pcm_search_cyclist to find the right ID.`, + const check = db.prepare( + "SELECT 1 FROM DYN_cyclist WHERE IDcyclist = ? LIMIT 1", ); + try { + 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.`, + ); + } + } finally { + check.free(); + } } const stmt = db.prepare( From 324d1243f975323c58106a175850e166427e080d Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Sat, 4 Jul 2026 16:14:24 -0400 Subject: [PATCH 8/8] Update Readme.md Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1578cdf..fa981e9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm | **pcm_search_team** | Search for a team by name (case-insensitive partial match against both the full name and short name). Returns up to 10 matches with the resolved division name, country name, evaluation and general manager. | | **pcm_query_save** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table in a save file. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | | **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. | -| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (plain, mountain, medium mountain, downhilling, cobble, time trial, prologue, sprint, acceleration, endurance, resistance, recuperation, hill, baroudeur; 55–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | +| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (`plain`, `mountain`, `mediumMountain`, `downhilling`, `cobble`, `timeTrial`, `prologue`, `sprint`, `acceleration`, `endurance`, `resistance`, `recuperation`, `hill`, `baroudeur`; 55–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | | **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). | ## How it works