From 5717eafb0c0b4d85a8c87ad3829ee3b9fb98ac47 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 2 Jul 2026 07:04:02 -0400 Subject: [PATCH 1/3] docs: add DATABASE.md to document save database conventions and structure --- AGENTS.md | 3 +++ DATABASE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 DATABASE.md diff --git a/AGENTS.md b/AGENTS.md index d92daa0..6b370d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,9 @@ 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). Consult it + before writing queries or joins. - **Tool responses** go through `validResponse` / `errorResponse`; declare both `inputSchema` and `outputSchema` with zod. - **Tool annotations** — every tool must include `readOnlyHint`, `destructiveHint`, diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..2e6d4b6 --- /dev/null +++ b/DATABASE.md @@ -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. From 7c4918c8d5ca11e80ec0a95477be9ddf19d129ea Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 2 Jul 2026 07:04:08 -0400 Subject: [PATCH 2/3] docs: enhance documentation for database conventions and integrate DATABASE.md as a resource --- AGENTS.md | 10 +++++++++- README.md | 8 ++++++++ src/index.ts | 2 ++ src/md.d.ts | 6 ++++++ src/reference.ts | 15 +++++++++++++++ src/resources/database.ts | 24 ++++++++++++++++++++++++ src/tools/query-save.ts | 11 +++++++++-- tsup.config.ts | 6 ++++++ vitest.config.ts | 18 ++++++++++++++++++ 9 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/md.d.ts create mode 100644 src/reference.ts create mode 100644 src/resources/database.ts diff --git a/AGENTS.md b/AGENTS.md index 6b370d9..8e83949 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ cyclist.ts # shared cyclist ratings: ratingsSchema / ratingsColumns() / mapRatings() tools/ @@ -79,7 +82,12 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive 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). Consult it - before writing queries or joins. + before writing queries or joins. It is the single source of truth and is + surfaced to the LLM client at runtime via `src/reference.ts` — embedded in the + `pcm_query_save` description and served as the `pcm://docs/database` resource. + Its contents are inlined into the bundle as a string at build time (esbuild + `text` loader in `tsup.config.ts`; mirrored by a Vite plugin in + `vitest.config.ts`), so nothing ships alongside `dist/`. - **Tool responses** go through `validResponse` / `errorResponse`; declare both `inputSchema` and `outputSchema` with zod. - **Tool annotations** — every tool must include `readOnlyHint`, `destructiveHint`, diff --git a/README.md b/README.md index cddd1ba..0b77b03 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ All tools are prefixed with `pcm_`, are read-only, and carry `readOnlyHint: true | **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_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. | + +The same reference is embedded in the `pcm_query_save` tool description, so clients that don't surface MCP resources still get the query conventions in context. + ## How it works Tools are **stateless**: there is no "current save" held by the server. Every save-reading tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The on-disk save is the single source of truth and is never mutated. A typical flow is: diff --git a/src/index.ts b/src/index.ts index 86f6f6b..9c3f2de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ 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 { registerDatabaseResource } from "./resources/database"; import { registerTools } from "./tools/index"; const { version } = JSON.parse( @@ -14,6 +15,7 @@ const server = new McpServer({ }); registerTools(server); +registerDatabaseResource(server); async function main() { const transport = new StdioServerTransport(); diff --git a/src/md.d.ts b/src/md.d.ts new file mode 100644 index 0000000..525724d --- /dev/null +++ b/src/md.d.ts @@ -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; +} diff --git a/src/reference.ts b/src/reference.ts new file mode 100644 index 0000000..f147417 --- /dev/null +++ b/src/reference.ts @@ -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"; diff --git a/src/resources/database.ts b/src/resources/database.ts new file mode 100644 index 0000000..4bc7ed3 --- /dev/null +++ b/src/resources/database.ts @@ -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, + }, + ], + }), + ); +} diff --git a/src/tools/query-save.ts b/src/tools/query-save.ts index ea0a138..7483be4 100644 --- a/src/tools/query-save.ts +++ b/src/tools/query-save.ts @@ -1,10 +1,18 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { DATABASE_REFERENCE } from "../reference"; import { withSaveDb } from "../save-db"; const DEFAULT_LIMIT = 100; const MAX_LIMIT = 1000; +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 DATABASE_REFERENCE ? `${base}\n\n${DATABASE_REFERENCE}` : base; +} + const outputSchema = z.object({ columns: z.array(z.string()).describe("Column names returned by the query"), rows: z @@ -22,8 +30,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 diff --git a/tsup.config.ts b/tsup.config.ts index 5f76f8f..8f6bb7d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -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", + }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 2075a31..49f35a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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"], From 8956ace65fb79399aff3bcdd40c5c5437b607fc3 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 2 Jul 2026 07:16:25 -0400 Subject: [PATCH 3/3] docs: update AGENTS.md and README.md for database conventions; add condensed cheatsheet to query-save tool --- AGENTS.md | 22 +++++++++++++------ README.md | 6 ++--- src/index.ts | 8 +++---- .../{database.ts => database-reference.ts} | 0 src/resources/index.ts | 6 +++++ src/tools/query-save.ts | 17 ++++++++++++-- 6 files changed, 42 insertions(+), 17 deletions(-) rename src/resources/{database.ts => database-reference.ts} (100%) create mode 100644 src/resources/index.ts diff --git a/AGENTS.md b/AGENTS.md index 8e83949..fb45fe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,13 +81,21 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive - **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). Consult it - before writing queries or joins. It is the single source of truth and is - surfaced to the LLM client at runtime via `src/reference.ts` — embedded in the - `pcm_query_save` description and served as the `pcm://docs/database` resource. - Its contents are inlined into the bundle as a string at build time (esbuild - `text` loader in `tsup.config.ts`; mirrored by a Vite plugin in - `vitest.config.ts`), so nothing ships alongside `dist/`. + 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`, diff --git a/README.md b/README.md index 0b77b03..59e0c47 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,10 @@ All tools are prefixed with `pcm_`, are read-only, and carry `readOnlyHint: true ## Resources -| URI | Description | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | -The same reference is embedded in the `pcm_query_save` tool description, so clients that don't surface MCP resources still get the query conventions in context. - ## How it works Tools are **stateless**: there is no "current save" held by the server. Every save-reading tool takes an absolute `savePath`, re-validates it, and re-reads the `.cdb` from disk into a fresh in-memory SQLite database (via [`cdb-converter`](https://www.npmjs.com/package/cdb-converter) + [`sql.js`](https://www.npmjs.com/package/sql.js)) for each call. The on-disk save is the single source of truth and is never mutated. A typical flow is: diff --git a/src/index.ts b/src/index.ts index 9c3f2de..63c394d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +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 { registerDatabaseResource } from "./resources/database"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { registerRessources } from "./resources"; import { registerTools } from "./tools/index"; const { version } = JSON.parse( @@ -15,7 +15,7 @@ const server = new McpServer({ }); registerTools(server); -registerDatabaseResource(server); +registerRessources(server); async function main() { const transport = new StdioServerTransport(); diff --git a/src/resources/database.ts b/src/resources/database-reference.ts similarity index 100% rename from src/resources/database.ts rename to src/resources/database-reference.ts diff --git a/src/resources/index.ts b/src/resources/index.ts new file mode 100644 index 0000000..cf772eb --- /dev/null +++ b/src/resources/index.ts @@ -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); +} diff --git a/src/tools/query-save.ts b/src/tools/query-save.ts index 7483be4..e4917d3 100644 --- a/src/tools/query-save.ts +++ b/src/tools/query-save.ts @@ -1,16 +1,29 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { DATABASE_REFERENCE } from "../reference"; +import { DATABASE_REFERENCE_URI } from "../reference"; 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 DATABASE_REFERENCE ? `${base}\n\n${DATABASE_REFERENCE}` : base; + return `${base}\n\n${SCHEMA_CHEATSHEET}`; } const outputSchema = z.object({