Skip to content
Merged
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ src/
get-table-schema.ts # pcm_get_table_schema
get-player-info.ts # pcm_get_player_info
search-cyclist.ts # pcm_search_cyclist
search-team.ts # pcm_search_team
query-save.ts # pcm_query_save
test/ # vitest specs (test/**/*.test.ts)
```
Expand All @@ -53,6 +54,7 @@ All tools are prefixed with `pcm_` and carry `readOnlyHint: true` / `destructive
| `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_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). |

## Conventions
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ All tools are read-only and carry `readOnlyHint: true`, so clients like Claude D
| **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. `mediumMountain` and `currentAbility` are `null` on saves that pre-date those columns. |
| **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). |

## Development
Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { registerGetTableSchema } from "./get-table-schema";
import { registerGetPlayerInfo } from "./get-player-info";
import { registerQuerySave } from "./query-save";
import { registerSearchCyclist } from "./search-cyclist";
import { registerSearchTeam } from "./search-team";

export function registerTools(server: McpServer): void {
registerListSaves(server);
Expand All @@ -15,4 +16,5 @@ export function registerTools(server: McpServer): void {
registerGetPlayerInfo(server);
registerQuerySave(server);
registerSearchCyclist(server);
registerSearchTeam(server);
}
102 changes: 102 additions & 0 deletions src/tools/search-team.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { withSaveDb } from "../save-db";

const teamSchema = z.object({
id: z.number().describe("Team ID (IDteam)"),
name: z.string().describe("Team name (gene_sz_name)"),
shortName: z.string().describe("Team short name (gene_sz_shortname)"),
division: z
.string()
.nullable()
.describe("Division name (STA_division.CONSTANT via fkIDdivision)"),
country: z
.string()
.nullable()
.describe("Country name (STA_country.gene_sz_flag via fkIDcountry)"),
evaluation: z
.number()
.describe("Team current evaluation (value_f_current_evaluation)"),
manager: z
.string()
.describe("General manager name (gene_sz_manager_general)"),
});

const outputSchema = z.object({
teams: z.array(teamSchema).describe("Matching teams"),
});

export function registerSearchTeam(server: McpServer): void {
server.registerTool(
"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 10 matching teams with their division name, country name, evaluation and general manager.",
inputSchema: {
savePath: z.string().describe("Absolute path to the .cdb save file"),
name: z
.string()
.describe(
"Team name to search for (partial match, case-insensitive)",
),
},
outputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ savePath, name }) =>
withSaveDb(savePath, (db) => {
const stmt = db.prepare(
`SELECT
t.IDteam AS id,
t.gene_sz_name AS name,
t.gene_sz_shortname AS shortName,
d.CONSTANT AS division,
c.gene_sz_flag AS country,
t.value_f_current_evaluation AS evaluation,
t.gene_sz_manager_general AS manager
FROM DYN_team t
LEFT JOIN STA_division d ON t.fkIDdivision = d.IDdivision
LEFT JOIN STA_country c ON t.fkIDcountry = c.IDcountry
WHERE LOWER(t.gene_sz_name) LIKE LOWER(:name)
OR LOWER(t.gene_sz_shortname) LIKE LOWER(:name)
LIMIT 10`,
);

const teams: z.infer<typeof teamSchema>[] = [];
try {
const query = name.trim();
if (query.length === 0) {
throw new Error("Provide a non-empty name to search for.");
}

stmt.bind({ ":name": `%${query}%` });

while (stmt.step()) {
const row = stmt.getAsObject();
teams.push({
id: Number(row.id),
name: String(row.name),
shortName: String(row.shortName),
division: row.division != null ? String(row.division) : null,
country: row.country != null ? String(row.country) : null,
evaluation: Number(row.evaluation),
manager: String(row.manager),
});
}
} finally {
stmt.free();
}

const output: z.infer<typeof outputSchema> = {
teams,
};
return output;
}),
);
}
Loading