Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ src/
saves.ts # save discovery + validation (listSaves, validateSave, getPcmRoot)
save-db.ts # withSaveDb(): open .cdb in-memory, run fn, always close db; getGameDate()
helpers.ts # validResponse / errorResponse → CallToolResult; ageFromYmd(); buildStartlistXml
reference.ts # loads DATABASE.md (DATABASE_REFERENCE) for the query tool + resource
resources/
database.ts # pcm://docs/database resource (serves DATABASE.md)
schemas/
Comment on lines +32 to 35
cyclist.ts # shared cyclist ratings: ratingsSchema / ratingsColumns() / mapRatings()
tools/
Expand Down Expand Up @@ -77,6 +80,22 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive
forbidden-keyword guard).
- **Guard against SQL injection** when interpolating identifiers: validate table
names against `DB_STRUCTURE` before building queries (see `get_table_schema`).
- **Save database conventions** (table prefixes, column typing, foreign keys,
display columns) are documented in [`DATABASE.md`](DATABASE.md), the single
source of truth. It is surfaced to the LLM two ways: the full document is
served as the `pcm://docs/database` resource (imported in `src/reference.ts`
and inlined into the bundle at build time via the esbuild `text` loader in
`tsup.config.ts`, mirrored by a Vite plugin in `vitest.config.ts`, so nothing
ships alongside `dist/`); a **condensed cheatsheet** (`SCHEMA_CHEATSHEET` in
`query-save.ts`) is kept in the `pcm_query_save` description to stay cheap on
every turn.
- **Whenever you change [`DATABASE.md`](DATABASE.md), update the
`SCHEMA_CHEATSHEET` in `query-save.ts` in the same change** so the tool
description stays in sync. The resource picks up `DATABASE.md`
automatically, but the cheatsheet is a hand-maintained summary and will
drift otherwise. Only the essentials (table/column prefixes, the FK
pattern with its key exceptions, display columns) belong in the cheatsheet —
the full detail lives in `DATABASE.md` / the resource.
- **Tool responses** go through `validResponse` / `errorResponse`; declare both
`inputSchema` and `outputSchema` with zod.
- **Tool annotations** — every tool must include `readOnlyHint`, `destructiveHint`,
Expand Down
56 changes: 56 additions & 0 deletions DATABASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# PCM save database reference

Pro Cycling Manager stores a career as a binary `.cdb` file. This server converts
it to an in-memory SQLite database on every call via `cdb-converter` (`cdbToSql`).

## Table prefixes

The prefix of a table name tells you what it holds:

| Prefix | Meaning | Contents | Examples |
| -------------- | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `DYN_` | **Dynamic** | Career state that mutates as the career is played — the actual save data | `DYN_cyclist`, `DYN_team`, `DYN_contract_cyclist`, `DYN_finance`, `DYN_transfer`, `DYN_news` |
| `STA_` | **Static** | Reference/lookup catalogs and enums, largely constant across a career | `STA_country`, `STA_division`, `STA_race`, `STA_type_rider`, `STA_stage` |
| `GAM_` | **Game** | Career session config and player state | `GAM_config`, `GAM_user`, `GAM_career_data`, `GAM_calendar_event` |
| `INF_` | Preset | Rare preset table | `INF_contract_preference_preset` |
| `DB_STRUCTURE` | Meta | Lists every table; used to validate table names before interpolating them | — |

This `DYN_` vs `STA_` split is what drives the display-column rule below.

## Column naming encodes the type

The prefix of a _column_ name tells you its type:

| Column prefix | Type | SQLite affinity |
| --------------------- | ----------------------- | --------------- |
| `_i_` / `fkID` / `ID` | integer | `INTEGER` |
| `_sz_` | string | `TEXT` |
| `_f_` | float | `REAL` |
| `_b_` | boolean | `NUMERIC` |
| `_ilist_` | serialized list of ints | `TEXT` |

Note that `cdb-converter` appends a numeric offset to the declared type
(e.g. `INTEGER 499717`), so match on affinity / `startsWith`, not equality.

## Foreign keys

Foreign keys follow `fkID{Suffix}` → `{DYN|STA|GAM}_{Suffix}`, joined on the
target table's `ID{Suffix}`. The suffix is _semantic_, not literal, so watch for
exceptions:

- `fkIDteam_duplicate` → `DYN_team` (`IDteam`)
- `fkIDnextdivision` → `STA_division` (`IDdivision`)
- `fkIDfirst_stage` / `fkIDlast_stage` → `STA_stage`

All joins in the tools are hand-written — there is no generic FK resolver.

## Display columns for FK lookups

Which column carries a human-readable label depends on the table family:

- **Dynamic tables (`DYN_*`)** expose a name in `gene_sz_name` (e.g. `DYN_team`).
- **Static lookup tables (`STA_*`)** usually key off `CONSTANT` (an enum-like
string, e.g. `STA_division`, `STA_type_rider`), with exceptions such as
`STA_country.gene_sz_flag`.
- Columns named `gene_strID_*` are indices into a string table, **not** display
strings.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` is read-
| **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. |
| **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). |

## Resources

| URI | Description |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pcm://docs/database` | The save-database reference (`DATABASE.md`): table prefixes (`DYN_`/`STA_`/`GAM_`), column typing, foreign-key conventions and display columns. Read this to write correct `pcm_query_save` queries and joins. |

## 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:
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { registerRessources } from "./resources";
import { registerTools } from "./tools/index";

const { version } = JSON.parse(
Expand All @@ -14,6 +15,7 @@ const server = new McpServer({
});

registerTools(server);
registerRessources(server);
Comment on lines 5 to +18

async function main() {
const transport = new StdioServerTransport();
Expand Down
6 changes: 6 additions & 0 deletions src/md.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// `.md` files are imported as strings (esbuild `text` loader at build time,
// a matching Vite plugin under vitest). See `tsup.config.ts` / `vitest.config.ts`.
declare module "*.md" {
const content: string;
export default content;
}
15 changes: 15 additions & 0 deletions src/reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import databaseReference from "../DATABASE.md";

/**
* The `DATABASE.md` save-schema reference, inlined into the bundle at build time
* (esbuild `text` loader; see `tsup.config.ts`). `DATABASE.md` stays the single
* source of truth — its contents are embedded as a string, so nothing extra
* ships alongside `dist/` and there is no runtime file read.
*
* Surfaced to the LLM in the `pcm_query_save` description and via the
* `pcm://docs/database` resource.
*/
export const DATABASE_REFERENCE: string = databaseReference;

/** Canonical MCP resource URI for the save-schema reference. */
export const DATABASE_REFERENCE_URI = "pcm://docs/database";
24 changes: 24 additions & 0 deletions src/resources/database-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { DATABASE_REFERENCE, DATABASE_REFERENCE_URI } from "../reference";

export function registerDatabaseResource(server: McpServer): void {
server.registerResource(
"pcm-database-reference",
DATABASE_REFERENCE_URI,
{
title: "PCM save database reference",
description:
"Conventions for querying a PCM `.cdb` save: table prefixes (DYN_/STA_/GAM_), column typing, foreign keys, and display columns.",
mimeType: "text/markdown",
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: DATABASE_REFERENCE,
},
],
}),
);
}
6 changes: 6 additions & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerDatabaseResource } from "./database-reference";

export function registerRessources(server: McpServer): void {
registerDatabaseResource(server);
}
Comment on lines +4 to +6
24 changes: 22 additions & 2 deletions src/tools/query-save.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { DATABASE_REFERENCE_URI } from "../reference";
import { explainQueryError, parseSingleStatement } from "../helpers";
import { withSaveDb } from "../save-db";

const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;

/**
* A condensed save-schema cheatsheet kept in the tool description (returned on
* every turn) so the model can write correct joins without an extra lookup.
* Deliberately short — the full reference (FK exceptions, examples) lives in the
* `pcm://docs/database` resource; keep this in sync with `DATABASE.md`.
*/
const SCHEMA_CHEATSHEET = `## Save schema cheatsheet
Full reference: read the \`${DATABASE_REFERENCE_URI}\` resource.
- **Table prefixes**: \`DYN_\` = mutable career data (\`DYN_cyclist\`, \`DYN_team\`, \`DYN_contract_cyclist\`); \`STA_\` = static lookups/enums (\`STA_country\`, \`STA_race\`, \`STA_type_rider\`); \`GAM_\` = session/player state; \`DB_STRUCTURE\` lists every table.
- **Column type by prefix**: \`_i_\`/\`fkID\`/\`ID\` = INTEGER, \`_sz_\` = TEXT, \`_f_\` = REAL, \`_b_\` = boolean, \`_ilist_\` = serialized int list. Declared types carry a numeric offset (e.g. \`INTEGER 499717\`) — match on affinity, not equality.
- **Foreign keys**: \`fkID{Suffix}\` → \`{DYN|STA|GAM}_{Suffix}\`, joined on the target's \`ID{Suffix}\`. The suffix is semantic — watch exceptions (\`fkIDteam_duplicate\` → \`DYN_team.IDteam\`; \`fkIDnextdivision\` → \`STA_division.IDdivision\`).
- **Display labels**: \`DYN_*\` use \`gene_sz_name\`; \`STA_*\` usually key off \`CONSTANT\` (enum string), with exceptions like \`STA_country.gene_sz_flag\`. \`gene_strID_*\` are string-table indices, not labels.`;

function buildDescription(): string {
const base =
"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.";

return `${base}\n\n${SCHEMA_CHEATSHEET}`;
}

const outputSchema = z.object({
columns: z.array(z.string()).describe("Column names returned by the query"),
rows: z
Expand All @@ -23,8 +44,7 @@ export function registerQuerySave(server: McpServer): void {
"pcm_query_save",
{
title: "Query PCM save (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.",
description: buildDescription(),
inputSchema: {
savePath: z.string().describe("Absolute path to the .cdb save file"),
query: z
Expand Down
6 changes: 6 additions & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ export default defineConfig({
banner: {
js: "#!/usr/bin/env node",
},
// Inline DATABASE.md into the bundle as a string (import in src/reference.ts)
// so the query-save description / pcm://docs/database resource carry the
// reference without shipping the file alongside dist/.
loader: {
".md": "text",
},
});
18 changes: 18 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { readFileSync } from "node:fs";
import type { Plugin } from "vite";
import { defineConfig } from "vitest/config";

// Mirror esbuild's `.md` text loader (tsup.config.ts) so `import x from "*.md"`
// resolves to the file contents as a string under vitest.
function markdownAsText(): Plugin {
return {
name: "markdown-as-text",
enforce: "pre",
load(id) {
const [file] = id.split("?");
if (file.endsWith(".md")) {
return `export default ${JSON.stringify(readFileSync(file, "utf-8"))};`;
}
},
};
}

export default defineConfig({
plugins: [markdownAsText()],
test: {
environment: "node",
include: ["test/**/*.test.ts"],
Expand Down
Loading