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
96 changes: 96 additions & 0 deletions .agents/skills/pinpoint-mcp/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
name: pinpoint-mcp
description: Operational runbook and conventions for interacting with PinPoint via the MCP server. Use when querying or modifying PinPoint records (machines, issues, comments, PinballMap links, iScored IDs) through MCP tools, performing fleet status sweeps, or updating machines. Covers the pagination drain procedure, PinballMap linking flow, iScored game ID management, and safety/confirmation rules.
---

# PinPoint MCP Operations Guide

This skill governs interaction with PinPoint's Model Context Protocol (MCP) server for remote administration of the Austin Pinball Collective (APC) collection and issue tracker.

Every MCP call executes within Tim's admin identity (`accessLevel: "admin"`). Writes are audit-logged and attributed to Tim across timelines and notifications.

---

## 1. Tool Catalog & Target Conventions

### Target Identification

- **Machines**: Identified by `machine` parameter, which accepts **initials** (case-insensitive, e.g. `"MM"`, `"AFM"`, `"TZ"`) or machine UUID. Initials are the primary human-friendly key.
- **Issues**: Identified by **machine + issue number** (e.g. `machine: "MM"`, `number: 3`), mirroring the app URL `/m/<INITIALS>/i/<number>`.

### Summary of Tools

- `whoami`: Returns resolved identity, access level, client ID, and auth mode.
- `list_machines`: Lists cabinets with initials, name, availability, owner, and open issue counts.
- `get_machine`: Full machine detail including PinballMap link, iScored link, and open issues.
- `add_machine`: Create a new machine row.
- `update_machine`: Consolidated tool to update machine `name`, `presenceStatus`, `owner`, PinballMap link (`pinballmapMachineId` / `pinballmapExcluded`), lineup intent (`intent`), or `iscoredGameId`.
- `list_issues`: Lists issues across the collection or for a single machine with filters.
- `get_issue`: Full issue detail including plain-text description, assignee, reporter, and comment thread.
- `create_issue`: Files a new issue on a machine.
- `add_issue_comment`: Adds a comment to an issue thread.
- `update_issue`: Updates one or more issue fields (`title`, `status`, `severity`, `priority`, `frequency`, `assignee`).
- `search_pinballmap_catalog`: Two-step lookup in PinPoint's local PinballMap catalog mirror.

---

## 2. Paging & Mutating Worklists (The Drain Procedure)

When performing batch triage or sweeping a worklist (e.g., "put all off-the-floor machines on the floor", "triage all new issues", or "link unlinked cabinets"):

> [!WARNING]
> **Do NOT advance `offset += limit` when your operations mutate the rows you are filtering by.**

### The Offset Shifting Trap

When you query a filtered list (such as `list_machines(presence: "off_the_floor", offset: 0, limit: 10)`), and then update those 10 machines to `presenceStatus: "on_the_floor"`:

1. Those 10 machines immediately leave the `off_the_floor` filter.
2. All remaining matches shift up to fill the vacated positions.
3. If you subsequently request `offset: 10`, you skip over the 10 machines that just shifted into indices 0–9!

### The Canonical Drain Pattern

1. Always query with `offset: 0`.
2. Inspect and update the returned page.
3. Because the updated items leave the filter, re-query with `offset: 0` and let the list drain.
4. If there are specific rows you deliberately choose **not** to change, advance `offset` past _only_ those unchanged rows so they do not repeat.
5. The sweep is complete when a request returns an empty page (`count: 0`), **not** when `total: 0` (since unchanged rows hold `total` above 0).

---

## 3. PinballMap Machine Linking Procedure

PinPoint maintains a local mirror of the PinballMap catalog. Linking a machine requires a two-step lookup:

1. **Search Families**: Call `search_pinballmap_catalog(query: "title name")`.
- This returns edition families or standalone titles with an `editionCount`.
- If `machineGroupId` is `null` or `editionCount` is `1`, the game is standalone and its `pinballmapMachineId` is already returned. That is your answer.
2. **List Editions**: If `machineGroupId` is non-null and `editionCount > 1`:
- Call `search_pinballmap_catalog(machineGroupId: <id>)` to retrieve individual editions (Pro, Premium, LE, etc.).
- Verify `familyName` on the response to confirm you passed a family group ID, not an edition ID.
- Select the edition's `pinballmapMachineId`.
3. **Link via `update_machine`**:
- Call `update_machine(machine: "<INITIALS>", pinballmapMachineId: <pinballmapMachineId>, intent: "on" | "off" | "no_sync")`.
4. **Excluding Uncataloged Cabinets**:
- For homebrew or uncataloged one-offs: `update_machine(machine: "<INITIALS>", pinballmapExcluded: true, pinballmapExcludedReason: "Custom homebrew cabinet")`.
- `pinballmapMachineId` and `pinballmapExcluded` are mutually exclusive.

---

## 4. iScored High Scores Linking

PinPoint integrates with iScored for arcade leaderboard displays:

- **Link a game**: `update_machine(machine: "<INITIALS>", iscoredGameId: "<iscored_game_id>")`.
- **Clear a link**: `update_machine(machine: "<INITIALS>", iscoredGameId: null)` or pass an empty string.

---

## 5. Safety, Audit & Operator Confirmation

- **Inspect First**: Always inspect the target with `get_machine` or `get_issue` before performing an update.
- **Reversible vs Irreversible Actions**:
- Reversible: updating presence, owner, or name.
- Public/Notifying: `create_issue` and comments dispatch notifications to machine owners and watchers.
- **Explicit Confirmation**: Obtain user confirmation before executing batch sweeps or filing issues on someone else's behalf.
7 changes: 7 additions & 0 deletions .agents/skills/pinpoint-pinballmap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ Preserve the live client's serialized writes and bounded `429` handling. Reuse s
When showing data for a specific PinballMap location, use `pinballmapLocationUrl` for the required location-listing attribution; do not construct the URL or link only to the homepage.

Unit and E2E tests must use the mock client at the seam and committed captured fixtures. The fixture-refresh script is a deliberate manual GET-only operation, never test setup or a routine live call.

## MCP Catalog Linking & Lineup Intent

When managing PinballMap links via the PinPoint MCP server:

- Use `search_pinballmap_catalog` (2-step family → edition lookup) and `update_machine(machine, pinballmapMachineId: ..., intent: "on" | "off" | "no_sync")`.
- For the full 2-step procedure and mutual exclusion rules (`pinballmapExcluded`), see the `pinpoint-mcp` skill (`.agents/skills/pinpoint-mcp/SKILL.md`).
1 change: 1 addition & 0 deletions docs/feature-specs/iscored.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Removed 2026-09-16. The Info tab card's "View all on iScored" link replaces it:
| Spec | Code today | Resolution |
| :-- | :-- | :-- |
| §2.1–§2.2 machine linking in Manage tab | No form field in Manage tab | PP-h2bu.4 |
| §2.3 MCP tool | Handled via consolidated `update_machine` tool | PP-u4ab.18 |
| §4.1–§4.5 Info tab top scores card | Not yet rendered | PP-h2bu.4 |
| §6.1 Fleet overview column | Not yet rendered | PP-h2bu.5 |

Expand Down
4 changes: 1 addition & 3 deletions src/app/api/mcp/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ export const maxDuration = 60;
* each tool additionally runs `checkPermission()` underneath (defense in depth).
*
* Tools: the PinPoint tool catalog ({@link registerPinpointTools}) plus a
* `whoami` diagnostic used to validate the connection end-to-end. Deliberately
* no count here — that number goes stale every time a tool lands (PP-x8jb);
* `registerPinpointTools` is the list.
* `whoami` diagnostic used to validate the connection end-to-end.
*/
const handler = createMcpHandler(
(server) => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/tools/add-issue-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function registerAddIssueComment(server: McpServer): void {
{
title: "Comment on an issue",
description:
"Post a comment on an issue, attributed to the authenticated user. Identify the issue by machine (initials or UUID) plus the issue number shown in its URL and returned by list_issues, get_machine, and create_issue. Plain text only — markdown is not rendered. Retrying an identical comment shortly after one usually resolves to the comment already posted instead of a duplicate — check 'created' in the response: false means nothing new was written, so report it as already posted rather than as a new comment.",
"Add a comment to an existing issue. Requires machine (initials or UUID), issue number, and comment text.",
inputSchema: addIssueCommentSchema,
annotations: WRITE_TOOL_ANNOTATIONS,
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/tools/create-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function registerCreateIssue(server: McpServer): void {
{
title: "Create issue",
description:
"File an issue against a machine (identified by initials or UUID). Requires a title; optional plain-text description, severity (cosmetic/minor/major/unplayable), priority (low/medium/high), and frequency (intermittent/frequent/constant). Attributed to the authenticated admin. Retrying an identical call shortly after one usually resolves to the issue already filed instead of a duplicate — check 'created' in the response: false means nothing new was written and 'number' refers to the pre-existing issue, so report it as already filed rather than as a new one.",
"File a new issue on a machine. Requires machine (initials or UUID) and title; accepts optional description, severity, priority, and frequency.",
inputSchema: createIssueSchema,
annotations: WRITE_TOOL_ANNOTATIONS,
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/tools/get-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function registerGetIssue(server: McpServer): void {
{
title: "Get issue detail",
description:
"Get one issue in full — title, description, status, severity, priority, frequency, reporter and assignee names, timestamps, URL, and the comment thread. Identify it by machine (initials or UUID) plus the issue number shown in its URL and returned by list_issues, get_machine, and create_issue. Use this before commenting or updating, so you are acting on the issue you think you are. The thread returns the MOST RECENT comments (20 by default, up to 100 via commentLimit), listed oldest-first within that window; 'commentCount' is the full thread length and 'commentsTruncated' is true when older comments were left out, so raise commentLimit if you need the earlier history. Timeline/system rows are not included in the thread; the issue's current status is what they would describe.",
"Get full details for an issue by machine (initials or UUID) and issue number: title, description, status, severity, priority, frequency, reporter/assignee names, timestamps, and recent comment thread.",
inputSchema: getIssueSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/tools/get-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function registerGetMachine(server: McpServer): void {
{
title: "Get machine detail",
description:
"Get one machine's detail — name, initials, availability (returned as `presence`), owner name, its iScored game ID (or null), its Pinball Map state (linked catalog title and edition, manufacturer, year, OPDB/IPDB, and the operator's lineup intent — `on`, `off`, or `no_sync` — which says whether it SHOULD be on the location's public lineup, not whether it currently is; or marked as not on Pinball Map; or null when neither has been recorded — and when a linked title is null, read `catalogLookup` before calling the link broken: `mirror_unpopulated` means PinPoint's catalog copy is empty, not that the link is stale), and its recent open issues (each with number, title, severity, status, and URL). Identify the machine by initials or UUID.",
"Get full details for a machine by initials or UUID: name, presence status, owner name, Pinball Map link state and lineup intent, and recent open issues.",
inputSchema: getMachineSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
Expand Down
19 changes: 5 additions & 14 deletions src/lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,17 @@ import { registerGetMachine } from "./get-machine";
import { registerListIssues } from "./list-issues";
import { registerListMachines } from "./list-machines";
import { registerSearchPinballmapCatalog } from "./search-pinballmap-catalog";
import { registerSetMachineAvailability } from "./set-machine-availability";
import { registerSetMachineIscored } from "./set-machine-iscored";
import { registerSetMachineName } from "./set-machine-name";
import { registerSetMachineOwner } from "./set-machine-owner";
import { registerSetMachinePinballmap } from "./set-machine-pinballmap";
import { registerUpdateIssue } from "./update-issue";
import { registerUpdateMachine } from "./update-machine";

/**
* Register the MCP tool catalog (spec §"Tool catalog") on an McpServer. Reads
* for disambiguation plus mutations, every one admin-gated at the door and
* `checkPermission`-gated per call.
*
* Two entities, each covered end to end: machines (list, read, add, rename,
* set availability, set owner, set PinballMap title) and issues (list, read,
* file, comment, update), plus the PinballMap catalog lookup that identifies a
* machine's title.
* Two entities, each covered end to end: machines (list, read, add, update)
* and issues (list, read, file, comment, update), plus the PinballMap catalog
* lookup that identifies a machine's title.
*
* This function is the catalog — a tool that ships without a call here is
* unreachable no matter how complete its handler is, which is what the
Expand All @@ -37,12 +32,8 @@ export function registerPinpointTools(server: McpServer): void {
registerListIssues(server);
registerGetIssue(server);
registerSearchPinballmapCatalog(server);
registerSetMachineAvailability(server);
registerSetMachineName(server);
registerAddMachine(server);
registerSetMachineOwner(server);
registerSetMachinePinballmap(server);
registerSetMachineIscored(server);
registerUpdateMachine(server);
registerCreateIssue(server);
registerAddIssueComment(server);
registerUpdateIssue(server);
Expand Down
19 changes: 5 additions & 14 deletions src/lib/mcp/tools/list-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const listIssuesSchema = z.object({
status: statusFilterSchema
.optional()
.describe(
"Which statuses to include: 'open' (the default), 'closed', one status, or an array of statuses. Open statuses are new, confirmed, wait_owner, in_progress, need_parts, need_help. Closed are fixed, wont_fix, wai, no_repro, duplicate."
"Which statuses to include: 'open' (default), 'closed', a single status, or an array of statuses."
),
severity: z
.enum(ISSUE_SEVERITY_VALUES)
Expand Down Expand Up @@ -93,9 +93,7 @@ const listIssuesSchema = z.object({
.int()
.min(0)
.optional()
.describe(
"How many matches to skip. Issues come back newest first, with machine initials and issue number breaking ties — a total order, so separate requests agree about where a page boundary falls, for as long as the underlying rows don't change. Whether you should advance this offset at all depends on whether your own calls change what matches; the tool description has the rule."
),
.describe("Number of matches to skip for pagination."),
});

type ListIssuesArgs = z.infer<typeof listIssuesSchema>;
Expand Down Expand Up @@ -192,26 +190,19 @@ export async function runListIssues(
}

/**
* Why the description repeats `list_machines`' drain procedure.
* Offset paging over a mutating result set.
*
* Offset paging is coherent only over a result set that holds still, and
* `update_issue` writes every field this tool filters on — `status` (which is
* also the DEFAULT filter), `severity`, and `assignee`. Working a filtered
* worklist while paging it is the normal use here rather than an edge case, so
* the failure sits on the common path: each issue actioned leaves the filter,
* the rest shift up, and `offset += limit` steps over exactly the ones that
* moved.
*
* Stated once in the description, for the model that has to follow it; this
* comment is the rationale, not a second copy.
* also the DEFAULT filter), `severity`, and `assignee`.
*/
export function registerListIssues(server: McpServer): void {
server.registerTool(
"list_issues",
{
title: "List issues",
description:
"Find issues across the whole collection, or on one machine. Every row carries the machine initials and issue number you need to act on it with get_issue, add_issue_comment, or update_issue. Filters: machine, status ('open' by default, or 'closed', or a specific set like ['need_parts','need_help']), severity, and assignee. Returns 'count' (this page), 'total' (every match), 'offset', and 'hasMore'. Answer counting questions from 'total', never from 'count' or the array length. To enumerate more than one page, keep requesting with offset += limit until hasMore is false — raising limit alone caps at 100 and will not reach the rest. That works only while the matching set holds still, and your own calls move it: update_issue changes status, severity, and assignee, which are exactly the filters here. So if you are ACTING on the issues as you page them — 'triage every new issue', 'close everything already fixed' — do NOT advance the offset. Each issue you action leaves the filter and the rest shift up, so offset += limit steps over exactly as many issues as you just handled, and the sweep ends on hasMore:false having never shown them. Re-request offset 0 and let the list drain instead. Raise offset only past issues you deliberately left unchanged, so they don't keep coming back. You are done when a request returns EMPTY (count 0), NOT when total reaches 0 — issues you left unchanged hold total above 0 forever.",
"List issues across the entire collection or on a specific machine. Supports filtering by machine (initials/UUID), status ('open', 'closed', or specific statuses), severity, and assignee. Returns paginated results with total count and hasMore.",
inputSchema: listIssuesSchema,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
},
Expand Down
Loading
Loading