{
  "name": "Chessfolio",
  "description": "Two capability sets over one MCP door. Personal, PAT-authenticated tools for the token owner's own chess data: stats, rating progress, games (list, detail, PGN attachment and review requests), problem opening lines, a cross-game weakness profile (ranked by phase, clock pressure, piece, move kind, concept tag and conversion, five-game floor, evidence game ids), the latest weekly report, puzzles, a personal study collection separate from played games (upload a PGN to study, list it, request review, delete it again), and durable coaching state (training focuses with a measured trend, an append-only coaching log, saved positions to quiz from). Nine narrow writes across those tools, none of which accepts a URL, a path or an uploaded file (what they take is PGN text, ids, labels and numbers sent in the call itself): attach_pgn and upload_study_game each accept exactly one validated PGN, for an exact owned game id and for a new study-collection row; request_game_review and request_study_analysis each queue (or wait for) an engine review of one owned game or study game; delete_study_game removes one game (and its notes) from the token owner's own study collection; set_training_focus creates, retitles or resolves one of at most three active training focuses, and never deletes one; add_coaching_entry appends one entry to an append-only coaching log with every game or position reference proved owned first and no entry's text ever edited, making two kinds of edit to an existing row: it flips one owned, OPEN assignment to completed or skipped when it is given that assignment's id, and a result naming a saved position also stamps that position's last-quizzed time and appends to its quiz history, which is how a quiz is recorded; save_position saves one position, copied from the owner's own review when a game is named, up to 500 per user; and remove_saved_position deletes one saved position, the only coaching row an agent can delete. The four coaching writes share one allowance of 60 an hour. None can edit results, ratings or metadata, none touches another user, and the user can delete any coaching row from their dashboard. Plus six PUBLIC, token-less tools: three tournament tools reading public Chess-Results data, an ECF rating calculator, and two tools reading a curated classic-games library (no per-ply data, prose and provenance only). Each tool's `auth` field says which it is.",
  "version": "1.7.1",
  "homepage": "https://chessfolio.io",
  "documentation": "https://chessfolio.io/developers",
  "auth": {
    "type": "bearer",
    "description": "Personal tools need Authorization: Bearer <token> — create a personal access token at chessfolio.io → Settings → API access. The public tools (auth: \"none\") need no token."
  },
  "remotes": [
    {
      "transport": "streamable-http",
      "endpoint": "https://chessfolio.io/api/mcp"
    }
  ],
  "tools": [
    {
      "name": "get_profile",
      "description": "The authenticated user's Chessfolio profile: display name, linked platform usernames, connected sources with sync freshness (last_synced_at), and total game count. Read-only.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    },
    {
      "name": "get_chess_stats",
      "description": "Aggregate statistics over the user's games for a chosen window: win/draw/loss by colour, online vs over-the-board comparison, performance rating, records and streaks, recent form, weekday performance, opponent-strength breakdown, game-length breakdown, top openings, monthly form and average accuracy. Same compute as the chessfolio.io dashboard.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "enum": [
              "30d",
              "90d",
              "1y",
              "all"
            ],
            "description": "Time window: 30d, 90d, 1y (default) or all."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "chesscom",
                "lichess",
                "ecf"
              ]
            },
            "description": "Restrict to sources: chesscom, lichess and/or ecf (OTB). Omit for all."
          },
          "timeClasses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "bullet",
                "blitz",
                "rapid",
                "standard"
              ]
            },
            "description": "Restrict to time-control buckets: bullet, blitz, rapid, standard. Omit for all."
          },
          "opening": {
            "type": "string",
            "description": "Opening-family prefix filter, e.g. 'Sicilian'."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "get_rating_progress",
      "description": "Rating series per provider and time control (including ECF over-the-board), each with start/end/delta over the window. Series longer than 60 points are evenly downsampled (first and last always kept) — pointsDownsampled says when. The window's opening value is seeded from the latest pre-window rating so deltas match the dashboard.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "enum": [
              "30d",
              "90d",
              "1y",
              "all"
            ],
            "description": "Time window: 30d, 90d, 1y (default) or all."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "chesscom",
                "lichess",
                "ecf"
              ]
            },
            "description": "Restrict to sources: chesscom, lichess and/or ecf (OTB). Omit for all."
          },
          "timeClasses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "bullet",
                "blitz",
                "rapid",
                "standard"
              ]
            },
            "description": "Restrict to time-control buckets: bullet, blitz, rapid, standard. Omit for all."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "list_games",
      "description": "Paged list of the user's games (50 per page) across chess.com, Lichess, ECF (OTB), manual and PGN imports, with filters matching the chessfolio.io games library: provider, result, colour, time class, date range, move-count range, opening, opponent, opening-line prefix (lineMoves, as returned by get_problem_lines) and free-text search. accuracy is the effective accuracy (platform value preferred, chessfolio review value as fallback — accuracySource says which).",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "page": {
            "type": "integer",
            "description": "1-based page number (50 games per page)."
          },
          "sort": {
            "type": "string",
            "enum": [
              "date-desc",
              "date-asc",
              "accuracy-desc",
              "accuracy-asc"
            ],
            "description": "Sort order. Default date-desc."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "chesscom",
                "lichess",
                "ecf",
                "manual",
                "pgn"
              ]
            },
            "description": "Sources to include. Omit for all."
          },
          "results": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "win",
                "draw",
                "loss"
              ]
            },
            "description": "Filter by result."
          },
          "colours": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "white",
                "black"
              ]
            },
            "description": "Filter by the user's colour."
          },
          "timeClasses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ultraBullet",
                "bullet",
                "blitz",
                "rapid",
                "classical",
                "daily",
                "correspondence",
                "standard"
              ]
            },
            "description": "Raw time classes to include."
          },
          "from": {
            "type": "string",
            "description": "Inclusive lower date bound, YYYY-MM-DD."
          },
          "to": {
            "type": "string",
            "description": "Inclusive upper date bound, YYYY-MM-DD."
          },
          "movesMin": {
            "type": "integer",
            "description": "Minimum full-move count."
          },
          "movesMax": {
            "type": "integer",
            "description": "Maximum full-move count."
          },
          "opening": {
            "type": "string",
            "description": "Opening-name prefix filter."
          },
          "opponent": {
            "type": "string",
            "description": "Opponent-name contains filter."
          },
          "lineMoves": {
            "type": "string",
            "description": "SAN opening-line prefix (use a lineMoves value from get_problem_lines)."
          },
          "q": {
            "type": "string",
            "description": "Free-text search over opponent, opening, event and ECO."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "get_game",
      "description": "One of the user's games in full: the same summary fields as list_games plus its moves and, where a Chessfolio review exists, a review summary. `movesSource` says what the moves are — `pgn` when the full game is stored (reviewed or PGN-attached games), `opening-only` when only the recorded opening line is known, or null for an OTB/online game with no attached PGN, which carries no moves at all (the endpoint never fabricates moves). `review` is present only when a Chessfolio analysis exists: accuracy for both colours and for the user, the key critical moments and a move-classification histogram (no per-ply detail). Read-only. `id` is a game id exactly as returned by list_games.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The game id, as returned by list_games."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "get_game_analysis",
      "description": "Move-by-move engine analysis of one of the user's reviewed games (the deterministic detail behind get_game's review summary). For each ply: the position before and after (FEN), the played move and the engine's best move in SAN and UCI, evaluations before and after, centipawn loss, the classification (book, brilliant, best, great, good, inaccuracy, mistake, miss, blunder) together with winProbabilityBefore and winProbabilityLoss — the pair the classifier actually judged, so a move can carry a large centipawnLoss and still be `good` when the position was already decided, and these two numbers say why rather than leaving the label looking wrong — the principal variation, whether the position was forced, the only-move margin, clock/think-time where the game carries clock data, the game `phase` (opening, middlegame or endgame by the lichess-divider-v1 piece-count rule, with book exit as the opening boundary), and `concepts`: deterministic tactical and positional tags derived only from the stored analysis (the ply's classification included), the game's clock readings and legal-move replay, never from a language model. Detector set v1: hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble. Each tag carries its evidence (squares, pieces and the capturing or best move, or the rule and clock bucket where the tag is not about one move), and an empty concepts list means no detector could support a claim, not that the move was sound; a stored position that will not parse, or a detector that fails, yields an empty list too. Every move returned is replayed and checked for legality first, and a stored line that will not replay is truncated at its last legal move (principalVariationTruncated says when). Paginated over plies (40 at a time, 120 maximum): pass fromPly to continue, side='user' for just the user's own moves, or verbosity='compact' to drop the position and engine-line reconstruction fields when sweeping a whole game. Nothing is invented: `availability` lists what this deployment cannot populate and why (human difficulty is not modelled; threat detection is partial, limited to the concepts listed; clocks are absent on games stored before clock capture and on over-the-board imports; win probability is absent on analyses stored before the api recorded it, and is never recomputed from the published evaluations to fill the gap). Every boolean in `availability` is about this deployment's coverage, never a finding about the game — `availability.legend` says so in the payload. Read-only: it serves the stored review and never starts engine work, so an unreviewed game returns analysed=false and tells you to call request_game_review.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The exact Chessfolio game id, as returned by list_games."
          },
          "fromPly": {
            "type": "integer",
            "minimum": 1,
            "description": "First ply to return, 1-based (ply 1 is White's first move). Use the nextFromPly value from a previous response to page. Default 1."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 120,
            "description": "How many plies to return, 1 to 120. Default 40."
          },
          "side": {
            "type": "string",
            "enum": [
              "both",
              "user"
            ],
            "description": "'user' returns only the user's own moves — usually what you want when coaching. Default 'both'."
          },
          "verbosity": {
            "type": "string",
            "enum": [
              "full",
              "compact"
            ],
            "description": "'compact' drops the four fields that exist to rebuild a position or an engine line (fenBefore, fenAfter, principalVariationUci, alternatives) and keeps every judgement, number, phase and concept tag — about a third fewer bytes per ply, more where the principal variation runs long. Use it to sweep a whole game; use 'full' (the default) when you need the positions, and get_critical_moments when you only want the moves that decided the game. The response's `verbosity.omittedPlyFields` lists exactly what was withheld, so an absent key is never mistaken for a field the analysis lacks."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "get_critical_moments",
      "description": "The teachable positions from one of the user's reviewed games, so you do not have to read every ply to find what matters. Each moment carries the position (FEN) and side to move, the played move and the engine's best move in SAN and UCI, the continuation the engine wanted, and a REFUTATION: the engine's own best line from the position the played move actually produced, i.e. how it should have been punished. Also: severity, the evaluation swing, a templated plain-English summary of the facts (summarySource says 'template'; no language model wrote it), progressive hints that narrow without ever naming the move, a training question, and acceptable answers (the best move, plus any alternative within 0.25 pawns of it). Each moment also carries `phase` (opening, middlegame or endgame, lichess-divider-v1) and `concepts`, the same deterministic v1 detector set as get_game_analysis (hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble), each with its evidence, so a coach can say 'left the bishop en prise to a fork' from facts rather than guesswork. A moment whose ply is missing from the stored per-ply analysis reports phase null and an empty concepts list, because there is then no position to divide or replay. Evaluations come in two frames: evalBefore/evalAfter are raw engine output and are SIDE-TO-MOVE relative, so evalAfter belongs to the OPPONENT and reads as its own opposite; evalBeforeMover/evalAfterMover restate both from the mover's side, which is the frame the summary narrates and the frame you want when quoting a number to a player. `lossReconciles` says whether centipawnLoss equals the mover-frame difference on that ply; it does not when the ±500 cap bit or the loss was forced to zero. `winProbabilityBefore`/`winProbabilityLoss` carry the pair `classification` was judged on, which is why a moment can pass a centipawn filter while its label stays mild: the thresholds widen as a position becomes decided. Every move is legality-checked before it is returned. `matchesKnownWeakness` lists which of the user's current top weaknesses (get_weakness_profile, default 12-month window) each of their OWN moments exhibits, matched on phase, clock bucket, piece moved, move kind and concept tags. It has three states and collapsing them into two is the error to avoid: null means no profile was supplied for this request (the usual cause being no reviewed game with stored features in the window, though a profile read that failed degrades to the same value), so nothing was checked at all; an empty array on one of their own moments means it was checked and nothing matched; a populated array is a match. With a profile loaded, an opponent's moment always reports an empty array, and there it means not-applicable rather than checked-and-clear, as does a moment whose ply is missing from the per-ply analysis. The `availability.matchesKnownWeakness` flag beside it says only whether a profile was loaded, which makes it a statement about THIS USER and the odd one out in a block of coverage flags; `availability.legend` sorts the rest into the ones this deployment fixes and the ones that turn on what this game carries. Read-only; an unreviewed game returns analysed=false.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The exact Chessfolio game id, as returned by list_games."
          },
          "side": {
            "type": "string",
            "enum": [
              "both",
              "user"
            ],
            "description": "'user' returns only the user's own moments. Default 'both'."
          },
          "minSeverity": {
            "type": "string",
            "enum": [
              "moderate",
              "major",
              "critical"
            ],
            "description": "Drop moments below this severity. Brilliancies are exempt — they are not a milder mistake. Omit for everything."
          },
          "minCentipawnLoss": {
            "type": "integer",
            "description": "Also surface any ply conceding at least this many centipawns (100 = one pawn), even where the engine recorded no moment. The engine's own gate is a 15% win-probability loss, which in a decided or quiet position can skip a 1.5-pawn error entirely — so this ADDS moments rather than filtering them, and each carries source='derived'."
          },
          "maxMoments": {
            "type": "integer",
            "description": "Cap the number returned (1-40, default 40). When more match than fit, the most severe are kept and returned in ply order — `matching` and `truncated` report what was left out."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "attach_pgn",
      "description": "Attach one complete PGN to an existing game in the authenticated user's Chessfolio library. Use an exact game `id` returned by list_games; this tool never guesses which row a score belongs to. It accepts PGN text only — no URL, path, binary file or metadata update — containing one parseable game with at least one move (maximum 100,000 characters; maximum 20 attachments per hour). Comments, annotations and side variations are removed and only parser-generated canonical PGN crosses the storage boundary. Only the attachment pointer changes: results, ratings and game metadata are untouched. The attachment makes the row reviewable but does not start engine analysis. Calls are idempotent: if the row already has moves, its existing PGN is kept and the response says alreadyAttached=true.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The exact Chessfolio game id, as returned by list_games."
          },
          "pgn": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "One complete PGN, including movetext. Headers are strongly recommended for checking the match."
          }
        },
        "additionalProperties": false,
        "required": [
          "id",
          "pgn"
        ]
      }
    },
    {
      "name": "request_game_review",
      "description": "Ask Chessfolio to run its engine review over one of the authenticated user's games. Use an exact game `id` returned by list_games. The game must already have moves — attach one first with attach_pgn if it does not. Analysis is queued and usually takes 20-40 seconds, so the default is to return immediately with status 'queued'; call this tool again with the same id to collect the finished review. Pass `wait` (seconds, 0-45; values outside that range are rejected) to have the server wait for it instead and return the review in one call. A game that has already been reviewed returns its existing review immediately with alreadyReviewed=true and costs nothing against the hourly limit — repeat calls are safe and free, and keep working even once the limit is exhausted, because the limit gates newly queued analysis only. Maximum 20 newly queued reviews per hour. Re-analysing an already-reviewed game is not offered.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The exact Chessfolio game id, as returned by list_games."
          },
          "wait": {
            "type": "integer",
            "minimum": 0,
            "maximum": 45,
            "description": "Seconds to wait for a queued analysis before giving up and returning 'queued'. Whole seconds from 0 to 45; anything outside that range is rejected, not clamped. Default 0 (return immediately)."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "get_problem_lines",
      "description": "The 'lines that keep hurting': per-colour opening lines (6–24 plies) where the user's score drops at least 8 points below their colour baseline over at least 5 games, ranked by a struggle index of (drop × log2 of games). Each line carries its SAN prefix (lineMoves) for drill-through into list_games, plus a ready-made games URL. OTB (ECF) games carry no move lists and are excluded — hasLineData says whether any analysable games exist in the window.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "enum": [
              "30d",
              "90d",
              "1y",
              "all"
            ],
            "description": "Time window: 30d, 90d, 1y (default) or all."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "chesscom",
                "lichess",
                "ecf"
              ]
            },
            "description": "Restrict to sources: chesscom, lichess and/or ecf (OTB). Omit for all."
          },
          "timeClasses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "bullet",
                "blitz",
                "rapid",
                "standard"
              ]
            },
            "description": "Restrict to time-control buckets: bullet, blitz, rapid, standard. Omit for all."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "get_weekly_report",
      "description": "The user's most recent Friday weekly report as its frozen payload: per-source sections (chess.com / Lichess / OTB) with games, rating lines, best win and toughest defeat, plus puzzles — exactly what the email and the public share page render, with the share URL included. Returns a clear message when no report exists yet (reports generate on Fridays for weeks with activity).",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    },
    {
      "name": "get_puzzle_stats",
      "description": "Cross-source puzzle activity and ratings: 12-week solve summary with win rate, volume bucketed to suit the window (day/week/month), and rating series for Lichess puzzles and chess.com tactics (chess.com is a peak-only rating — their API exposes no current value). Rating series longer than 120 rows are evenly downsampled, first and last kept (dataDownsampled says when).",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "enum": [
              "30d",
              "90d",
              "1y",
              "all"
            ],
            "description": "Time window: 30d, 90d, 1y (default) or all."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "list_study_games",
      "description": "The authenticated user's personal study collection — every game saved via upload_study_game, newest first, up to 200 rows. Each entry carries id, source, white/black names, event, year, result and when it was saved. This is a separate collection from the user's own played games (list_games): study games are things the user wants to STUDY, not games the user played, so there is no colour or accuracy-for-the-user field here. Call request_study_analysis with an id to have Chessfolio engine-review one. Read-only.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    },
    {
      "name": "upload_study_game",
      "description": "Paste one complete PGN into the authenticated user's personal study collection — for studying somebody else's game (a classic, a friend's game, one found online), not for the user's own played games (use attach_pgn against an existing list_games row for those). Validated like every PGN door on this api: exactly one parseable game, at least one move, legal throughout, no set-position (FEN/SetUp) games; maximum 100,000 characters; maximum 20 uploads per hour. Any prose in `{...}` comments is extracted and kept as editable per-ply study notes the user can read and edit in the web study viewer at /study — unlike attach_pgn, comments here are not simply discarded — though only the canonical, comment-free mainline is ever analysed or sent for engine review, and this tool surface has no way to read or write those notes itself. The collection is deduplicated on the MOVES, not the file: re-uploading a game whose headers, comments or clock tags differ from one already in the collection lands on that same row (alreadyInCollection=true) rather than creating a duplicate — its analysis is kept, and any new comments fill plies that don't already have a note; the old upload's own clock tags and other comment-only data are not merged in and are not stored anywhere. Separately, and independently of collection membership, Chessfolio's backing store reuses analysis whenever the full canonical text (headers and clocks included) byte-matches a game already analysed anywhere in Chessfolio's store — for example one copied from get_library_game — so request_study_analysis can return that analysis for free, no new engine time. Returns an id for request_study_analysis and list_study_games.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "pgn": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "One complete PGN, including movetext. Headers are strongly recommended (white/black/event/date), though only the movetext is required."
          }
        },
        "additionalProperties": false,
        "required": [
          "pgn"
        ]
      }
    },
    {
      "name": "request_study_analysis",
      "description": "Ask Chessfolio to run its engine review over one of the authenticated user's study games (as saved by upload_study_game). Spends from the SAME shared request_review allowance as request_game_review — 20 newly queued analyses per hour, atomic, shared across both the games surface and the study collection, because both doors ultimately queue work on one concurrency-1 engine rather than owning a ceiling each. A study game whose analysis already exists (including one that content-addressed onto an already-analysed api game) returns complete immediately, at zero cost against the limit — repeat calls are safe and free. Analysis is queued and usually takes 20-40 seconds, so the default is to return immediately with status 'queued'; call this tool again with the same id to collect the finished review, or pass wait (seconds, 0-45; values outside that range are rejected) to have the server wait for it instead. There is no accuracy 'for the user' here: a study game is somebody else's game, so only white and black accuracies are reported, never a colour-attributed one.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The study game id, as returned by upload_study_game or list_study_games."
          },
          "wait": {
            "type": "integer",
            "minimum": 0,
            "maximum": 45,
            "description": "Seconds to wait for a queued analysis before giving up and returning 'queued'. Whole seconds from 0 to 45; anything outside that range is rejected, not clamped. Default 0 (return immediately)."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "delete_study_game",
      "description": "Delete ONE game from the authenticated user's personal study collection, including its per-ply study notes (they cascade with the row). This cannot be undone — there is no recycle bin, and re-uploading the PGN later creates a fresh entry with a new id. What that means for notes: any note typed or edited directly in Chessfolio's study viewer is gone for good — it lived only on the deleted row. But a note that came from a `{...}` comment embedded in the PGN itself is not gone in the same sense: it lives in the PGN text, not the row, so re-uploading that same PGN re-seeds it as a fresh note on the new entry. Scope is deliberately narrow: it removes the study-collection row only, so it does NOT delete the underlying analysed game record in Chessfolio's backing store (a library game or another copy of the same game keeps its analysis) and it cannot touch any other user's data. `id` must come from list_study_games (or upload_study_game); an unknown id, an id belonging to somebody else and a malformed id all return the same not-found — the three are indistinguishable by design. Deleting a game the user PLAYED is not offered anywhere on this surface: list_games rows cannot be deleted, only study-collection rows can.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The study game id to delete, as returned by list_study_games or upload_study_game."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "get_weakness_profile",
      "description": "A ranked list of what is going wrong across the user's reviewed games in a window (range, default 1y): the cross-game diagnosis to run BEFORE picking a game to work on. Each reviewed game's OWN moves (never the opponent's) are bucketed on phase, clock pressure (scramble at 20 seconds or less left after the move, pressed at 60 or less, comfortable above, unknown with none stored), piece (SAN letters K, Q, R, B, N and P for pawn moves), move kind (capture, check, quiet, pawn_push, castle), concept tag (the v1 detector ids get_critical_moments reports) and colour, plus two per-game conversion buckets: conversion:winning (win probability reached 80%) and conversion:lost (fell to 20% or less). Move buckets report moves, inaccuracies, mistakes, misses, blunders and an error rate per 100 moves. Conversion buckets report a SUCCESS rate over games that had the situation AND a stored result, so `games` is not 'games I was winning': a game whose result Chessfolio does not hold is in neither `games` nor `won`. Buckets clearing five reviewed games are ranked by a struggle index, and the top N (1 to 10, default 5) come back as `weaknesses`: a stable id such as concept:hanging_piece or clock:scramble, a templated label, the numbers, a `delta` against the previous window of equal length (compareToPrevious, default true; range=all has none), and up to three `evidence` entries — a list_games game id, a ply, a templated fact — ready for get_critical_moments. That index is THREE scores, because the buckets are three different kinds of thing. Phase, clock, piece, kind and colour partition the user's moves, every move sitting in exactly one bucket of each, so those are scored on how far the bucket's error rate sits ABOVE the user's own overall rate, times log2 of the bucket's moves. Concept tags are not a partition: a move carries none, one or several, and most tags only ever attach to a move that was already a mistake, which makes a rate measured within the tag 100 by construction and tells you nothing. So a concept is measured over the WHOLE window instead. Its `moves` is every user ply in the window, its `ratePer100` reads as 'this habit costs me N errors per 100 moves', a tag that never fired reports errors 0 and rate 0 over that same full move count, and it is ranked only once it accounts for at least a QUARTER of every error in the window, scored as that rate times log2 of the window's moves. A concept's five-game floor likewise counts the games where the tag landed on an error, not the games it fired in. Conversion buckets score (1 minus the success rate) times log2 of games. Read `delta` against its own bucket: on a move bucket it is a change in an ERROR rate, so positive means worse; on a conversion bucket a change in a SUCCESS rate, so positive means better. `avgCollapsePly` averages every collapse in the bucket, including games the user went on to win: it says where the game turned, never which move lost it. Buckets under the floor go to `lowSample` with counts and no rank, so 'not enough data' never reads as 'not a problem'; `byDimension` carries every known bucket's counts either way. Honesty rules: nothing is claimed on fewer than five reviewed games, this window or the previous one, so a delta against three games comes back null; a partition bucket at or below the user's own overall rate scores 0, as does a concept tag holding less than a quarter of the errors, so a player whose errors are evenly spread across their tags gets no concept in the list at all — read a short list of low-scoring partition buckets as 'nothing stands out', because over a few dozen games some bucket always sits a point or two above the overall rate by chance; clock:unknown and concept:time_trouble are counted in byDimension but reach neither `weaknesses` nor `lowSample`, missing clock data being a gap in the record rather than a habit, and the time-trouble tag being exactly the clock:pressed and clock:scramble errors under a second name, so ranking it would count the same errors twice; over-the-board (ECF) games carry no moves and are excluded; and only games with a stored Chessfolio review count (`reviewedGames` says how many), so hasReviewData=false means nothing reviewed falls in this window: call request_game_review and try again. No engine runs and no position is replayed: it aggregates features stored when each review landed, every string templated from them (summarySource: 'template'; no language model wrote it). Read-only. Counts against the 10-a-minute weakness-profile allowance, its own bucket, shared with GET /api/v1/me/weakness-profile and separate from the door's shared ceiling: one call aggregates the whole window and, unless compareToPrevious is false, the window before it, so it is not priced as an ordinary read.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "range": {
            "type": "string",
            "enum": [
              "30d",
              "90d",
              "1y",
              "all"
            ],
            "description": "Time window: 30d, 90d, 1y (default) or all."
          },
          "providers": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "chesscom",
                "lichess",
                "ecf"
              ]
            },
            "description": "Restrict to sources: chesscom, lichess and/or ecf (OTB). Omit for all."
          },
          "timeClasses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "bullet",
                "blitz",
                "rapid",
                "standard"
              ]
            },
            "description": "Restrict to time-control buckets: bullet, blitz, rapid, standard. Omit for all."
          },
          "compareToPrevious": {
            "type": "boolean",
            "description": "Also compute the same-length window immediately before this one and report deltas. Default true. Ignored for range=all, which has no previous window."
          },
          "top": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "description": "How many ranked weaknesses to return, 1 to 10. Default 5. A value outside that range is rejected here, not clamped. Fewer than `top` come back whenever fewer buckets clear the floor and score above 0."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "get_coaching_state",
      "description": "What you and this user have agreed to work on, read in one call: make this your FIRST call in any coaching session. Returns the user's ACTIVE training focuses (at most three), each with the profile bucket snapshot taken when it was set (`baseline`), the same bucket from the current 90-day get_weakness_profile window (`now`), and a `trend` of improving / flat / worse (a move of at least 10% either way) or unknown when either side is missing; the trend is arithmetic over those two numbers and nothing more, with no minimum-games floor on either side: unlike get_weakness_profile, which claims nothing on fewer than five reviewed games, a baseline or a current reading here can each rest on as little as one, so a focus can flip to worse or improving on the strength of a single game. Also the last 20 coaching entries of every kind, newest first; the assignments still `open`; how many positions are saved; and `lastSessionAt`, the time of the most recent `session` entry. Resolved focuses (done or dropped) are not returned. Every string is templated from stored facts (`summarySource: \"template\"`): nothing on this response was written by a language model on the server, and a user with no coaching rows gets empty lists, not an error. Read-only; spends only the shared 120/minute limit.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    },
    {
      "name": "set_training_focus",
      "description": "Create, retitle or resolve ONE training focus for the authenticated user: a short statement of what you and the user are working on, e.g. \"Stop hanging pieces\". With no `id` it creates a focus (`title` required, 1-120 characters). Pass `weaknessId` (an id exactly as returned by get_weakness_profile, such as concept:hanging_piece or clock:scramble) to snapshot that bucket's current 90-day figure as `baseline`, which is what makes get_coaching_state's trend possible; without one the trend is always unknown. With an `id` (from get_coaching_state) it retitles (`title`) and/or resolves the focus: `status` done or dropped stamps `resolvedAt`; `status` active re-opens one. At most THREE focuses can be active at once: a fourth returns an error naming the three so you can resolve one first. Resolving is the only way through, because this tool never deletes. Scope: it touches one coaching_focus row owned by the token holder and nothing else, so no games, no study collection, no entries and no other user. Rows are attributed to the token that wrote them (`createdBy: agent:<token prefix>`), and the user can delete any focus from their dashboard at any time. Counts against the 60-per-hour coaching-write allowance shared by set_training_focus, add_coaching_entry, save_position and remove_saved_position.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 64,
            "description": "An existing focus id from get_coaching_state, to retitle or resolve it. Omit to create a new focus."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "What the user is working on, 1-120 characters. Required when creating."
          },
          "weaknessId": {
            "type": "string",
            "maxLength": 64,
            "description": "A weakness id from get_weakness_profile (e.g. concept:hanging_piece). Snapshots that bucket as the baseline the trend is measured from."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "done",
              "dropped"
            ],
            "description": "With id: done or dropped resolves the focus; active re-opens a resolved one (subject to the three-active cap)."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "add_coaching_entry",
      "description": "Append ONE entry to the user's coaching log: a `reflection` (what the user said or noticed), a `note` (yours), an `assignment` (homework, which starts `open` unless you pass `status`), a `result` (how a quiz or assignment went) or a `session` (a summary of this session; get_coaching_state's lastSessionAt reads the newest of these). `body` is 1-2000 characters of plain text. Optionally file it against a focus (`focusId`) and pin it to evidence with `gameId` (one of the user's own games from list_games), `ply` (with gameId), `fen` (validated and normalised) and/or `positionId` (a saved position from list_saved_positions); every ref is checked before anything is stored, and a ref the user does not own is rejected, never stored. RECORDING A QUIZ: `kind: \"result\"` with `positionId` and `correct: true|false` also stamps that position's lastQuizzedAt and appends to its quizResults, which is what list_saved_positions' dueForQuiz reads; there is no separate quiz tool. `status` (open / completed / skipped) is accepted on assignments only, at creation. CLOSING AN ASSIGNMENT: a `result` or `note` entry carrying `assignmentId` (from get_coaching_state's openAssignments) and `assignmentStatus` completed or skipped flips that ONE owned, OPEN assignment's status and nothing else; its text is never edited, it is never deleted, and an assignment that is not the user's own or not open is rejected. Beyond that single status flip, entries are APPEND-ONLY from this surface: nothing here edits an existing entry's text or deletes an entry, and the user can delete any entry from their dashboard. Never touches games, the study collection or another user's data. Counts against the shared 60-per-hour coaching-write allowance.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "reflection",
              "note",
              "assignment",
              "result",
              "session"
            ],
            "description": "What this entry is."
          },
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2000,
            "description": "Plain text, 1-2000 characters."
          },
          "focusId": {
            "type": "string",
            "maxLength": 64,
            "description": "File the entry under one of the user's focuses (id from get_coaching_state)."
          },
          "gameId": {
            "type": "string",
            "maxLength": 64,
            "description": "A game id from list_games this entry is about. Must be the user's own."
          },
          "ply": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "The ply within gameId, 1-based (1 = White's first move). Needs gameId."
          },
          "fen": {
            "type": "string",
            "maxLength": 100,
            "description": "A position this entry is about. Must be a legal FEN."
          },
          "positionId": {
            "type": "string",
            "maxLength": 64,
            "description": "A saved position from list_saved_positions. With kind result, records a quiz on it."
          },
          "assignmentId": {
            "type": "string",
            "maxLength": 64,
            "description": "An assignment entry's id: typically one from get_coaching_state's openAssignments, though any of the user's own assignment entries works here regardless of status. On its own, just a reference; with assignmentStatus on a result or note entry, closes that assignment, which must still be open."
          },
          "assignmentStatus": {
            "type": "string",
            "enum": [
              "completed",
              "skipped"
            ],
            "description": "With assignmentId, on a result or note entry: mark that OPEN assignment completed or skipped. Its text is never changed and it is never deleted."
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "completed",
              "skipped"
            ],
            "description": "Assignments only. Default open."
          },
          "correct": {
            "type": "boolean",
            "description": "With kind result and positionId: whether the user found an acceptable answer. Default false."
          }
        },
        "additionalProperties": false,
        "required": [
          "kind",
          "body"
        ]
      }
    },
    {
      "name": "save_position",
      "description": "Save ONE position for the user to come back to: a FEN with a `label` (1-80 characters, e.g. \"Knight fork you missed vs X\") and an optional `why` (up to 500). Two ways in. (1) `gameId` + `ply`: the FEN, the engine's best move and the acceptable answers (best move plus any alternative within 0.25 pawns, the same rule get_critical_moments uses) are COPIED from the user's stored review of that ply; a fen passed alongside gameId is rejected, not trusted. The game must be the user's own (list_games) and already reviewed (request_game_review first). (2) A bare `fen`: validated and normalised; `bestMove` is null and `acceptableAnswers` is empty, because Chessfolio runs no engine here and will not invent an answer. Saving the same FEN from the same source again returns the existing row with `alreadySaved: true` rather than a duplicate; a bare FEN and the same FEN via a game are different sources. Optionally file it against a focus (`focusId`). At most 500 saved positions per user: the 501st is rejected until one is removed. Touches one coaching_positions row owned by the token holder and nothing else; the user can delete any saved position from their dashboard. Counts against the shared 60-per-hour coaching-write allowance.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "fen": {
            "type": "string",
            "maxLength": 100,
            "description": "The position to save, as a FEN. Not with gameId."
          },
          "gameId": {
            "type": "string",
            "maxLength": 64,
            "description": "One of the user's reviewed games (list_games). The position is copied from its review."
          },
          "ply": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "The ply within gameId whose position (before the move) to save. Required with gameId."
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "A short name for the position, 1-80 characters."
          },
          "why": {
            "type": "string",
            "maxLength": 500,
            "description": "Why it matters, up to 500 characters."
          },
          "focusId": {
            "type": "string",
            "maxLength": 64,
            "description": "File it under one of the user's focuses (id from get_coaching_state)."
          }
        },
        "additionalProperties": false,
        "required": [
          "label"
        ]
      }
    },
    {
      "name": "list_saved_positions",
      "description": "The user's saved positions, newest first, 25 per page (`page`), optionally filtered to one focus (`focusId`) or to those `dueForQuiz`: never quizzed, or whose most recent quiz result was wrong. Each carries the FEN and side to move, the label and why, where it came from (`source`: the game id and ply, or null for a bare FEN), `bestMove` and `acceptableAnswers` (SAN) where the position was saved from a reviewed ply, and its quiz history, so you can run a quiz without playing chess yourself: show the FEN, ask for a move, compare the answer to acceptableAnswers, then record it with add_coaching_entry (kind result, positionId, correct). A position saved from a bare FEN has no acceptable answers, so Chessfolio cannot mark it right or wrong. Read-only; `total` counts the rows matching the filter.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "page": {
            "type": "integer",
            "minimum": 1,
            "description": "1-based page number, 25 positions per page. Default 1."
          },
          "focusId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "Only positions filed under this focus."
          },
          "dueForQuiz": {
            "type": "boolean",
            "description": "Only positions never quizzed, or whose last quiz was wrong."
          }
        },
        "additionalProperties": false
      }
    },
    {
      "name": "remove_saved_position",
      "description": "Delete ONE saved position owned by the authenticated user, by the exact `id` from list_saved_positions. This cannot be undone. Scope is deliberately narrow: only that coaching_positions row goes; the game it was taken from, the user's review, any coaching entry that referenced it (its refs are left as they were) and every other user are untouched. An unknown id, an id belonging to somebody else and a malformed id all return the same not-found, indistinguishable by design. This is the only coaching row an agent can delete: focuses are resolved with set_training_focus, and entries cannot be removed from this surface at all; the user deletes those from their dashboard. Counts against the shared 60-per-hour coaching-write allowance.",
      "auth": "bearer",
      "input_schema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 64,
            "description": "The saved position id, as returned by list_saved_positions or save_position."
          }
        },
        "additionalProperties": false,
        "required": [
          "id"
        ]
      }
    },
    {
      "name": "list_library_games",
      "description": "Public, token-less listing of Chessfolio's curated classic-games library — around 50 published historical and instructive games, each with editorial commentary, a source citation and full engine analysis behind it. Returns one card per game (slug, title, white, black, event, year, result, ECO) so an agent can browse and pick one. Call get_library_game with a slug from this list for the full entry, including its editorial essay and canonical PGN. No token needed; no personal scope.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {},
        "additionalProperties": false
      }
    },
    {
      "name": "get_library_game",
      "description": "Public, token-less full entry for one published library game: the editorial essay, its source citation, the canonical PGN, and both players' overall engine accuracy — but deliberately NO per-ply data (no move evaluations, no critical moments, no depth): the library surface is prose and provenance, not an analysis feed, by hard design exclusion. To study the game move by move, paste its PGN into your own collection with upload_study_game — because the PGN is byte-identical to the one already analysed here, the analysis comes back at zero engine cost via content-addressed dedupe onto the already-analysed row. slug comes from list_library_games.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "maxLength": 80,
            "description": "The library game's slug, as returned by list_library_games."
          }
        },
        "additionalProperties": false,
        "required": [
          "slug"
        ]
      }
    },
    {
      "name": "get_tournament_state",
      "description": "Public, read-only snapshot of any Chess-Results tournament — no token needed (source: chess-results.com). Returns the seeded player list, published round pairings with results, current standings, and any not-paired / requested-bye / withdrawal notes, plus a snapshotAt timestamp. Treat it as a point-in-time scrape that MAY BE STALE, not a live feed — always read snapshotAt. tnr is the Chess-Results tournament number (e.g. 651260) or a full chess-results.com tournament URL.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {
          "tnr": {
            "type": "string",
            "description": "Chess-Results tournament number (e.g. 651260) or a full chess-results.com tournament URL."
          }
        },
        "additionalProperties": false,
        "required": [
          "tnr"
        ]
      }
    },
    {
      "name": "estimate_round1",
      "description": "Public, read-only ESTIMATE of Round-1 pairings for a Chess-Results tournament — no token needed (source: chess-results.com). This is an ESTIMATE derived from the seed list, NOT the official pairing: the arbiter's real draw can differ. The opponent estimate is more reliable than colour — Round-1 colours hinge on the initial-colour draw, so treat the estimated colour as a coin-flip. The underlying snapshot may be stale (see staleWarning in the payload). Optional target returns just one player's board by case-insensitive name. tnr is the Chess-Results tournament number (e.g. 651260) or a full URL.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {
          "tnr": {
            "type": "string",
            "description": "Chess-Results tournament number (e.g. 651260) or a full chess-results.com tournament URL."
          },
          "target": {
            "type": "string",
            "description": "Optional player name (case-insensitive) to return just their estimated board."
          }
        },
        "additionalProperties": false,
        "required": [
          "tnr"
        ]
      }
    },
    {
      "name": "estimate_pairings",
      "description": "Public, read-only ESTIMATE of the next round's pairings for a Chess-Results Swiss tournament — no token needed (source: chess-results.com). It runs the real FIDE Dutch pairing engine (bbpPairings) over the live standings, but it is an ESTIMATE, NOT the official pairing: the arbiter's Swiss-Manager draw can legitimately differ (accelerated pairings, custom settings, manual corrections). The older manual seeded-Swiss method is a teaching aid, not the target. Colours follow each player's prior-round colour history. Optional round picks which round to estimate (defaults to the next unplayed round). Optional target spotlights one player by case-insensitive name and returns their board plus a what-if scenario table. tnr is the Chess-Results tournament number (e.g. 651260) or a full chess-results.com tournament URL.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {
          "tnr": {
            "type": "string",
            "description": "Chess-Results tournament number (e.g. 651260) or a full chess-results.com tournament URL."
          },
          "round": {
            "type": "integer",
            "description": "round to estimate; defaults to the next unplayed round"
          },
          "target": {
            "type": "string",
            "description": "a player name to spotlight; returns their board + a what-if scenario table"
          }
        },
        "additionalProperties": false,
        "required": [
          "tnr"
        ]
      }
    },
    {
      "name": "calculate_ecf_rating_change",
      "description": "Public, token-less ECF rating calculator: work out what a set of results does to an English Chess Federation rating, using the ECF's published K Rating algorithm (V4, August 2020). Returns the new rating plus a per-game audit trail — rating difference, Elo difference-table offset, score offset and increment for each game — so the answer can be checked by hand. It is DETERMINISTIC ARITHMETIC, NOT AN OFFICIAL ECF FIGURE: the ECF rates a whole monthly cycle against one Old Rating carried in from the previous cycle, which is not always the rating published for the month the games were played, and it uses the opponent ratings held for that cycle. K Rating only, so it does not apply to new or partially-rated players (under 10 rated games), who are rated by the P (performance) algorithm instead.",
      "auth": "none",
      "input_schema": {
        "type": "object",
        "properties": {
          "currentRating": {
            "type": "integer",
            "description": "The player's ECF rating before these games, e.g. 1650. Four-digit scale (2020 onwards), not an old-style three-digit grade."
          },
          "games": {
            "type": "string",
            "description": "The games, as \"opponent rating then result\", comma-separated: \"1750 win, 1700 draw, 1600 loss\". w/d/l, 1/=/0 and +/-  also work, as do 1750=w and 1750:d. Up to 100 games. Pass the whole month together: the 700-point monthly cap is a per-month rule."
          },
          "age": {
            "type": "integer",
            "description": "The player's age in years. Only the under-18 boundary matters: a junior who is GAINING rating moves at K = 40 instead of 20, which roughly doubles the change. Omitted means treat as an adult."
          },
          "gamesThisMonth": {
            "type": "integer",
            "description": "Every rated game the player played in the rating month, if more than the ones listed here. The ECF caps a month's movement at 700 points by scaling K down, and that cap divides by this number. Omit when the games listed are all of them."
          }
        },
        "additionalProperties": false,
        "required": [
          "currentRating",
          "games"
        ]
      }
    }
  ]
}