From 7d2828dfb17b9db492fd73aaec948eb99df42f87 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 20 Jul 2026 17:19:00 +0200 Subject: [PATCH 01/12] wip --- .gitignore | 2 + setup.cfg | 13 +- taiga/mcp_server/__init__.py | 7 + taiga/mcp_server/auth.py | 70 ++++++++ taiga/mcp_server/cli.py | 75 ++++++++ taiga/mcp_server/serialize.py | 27 +++ taiga/mcp_server/server.py | 328 ++++++++++++++++++++++++++++++++++ 7 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 taiga/mcp_server/__init__.py create mode 100644 taiga/mcp_server/auth.py create mode 100644 taiga/mcp_server/cli.py create mode 100644 taiga/mcp_server/serialize.py create mode 100644 taiga/mcp_server/server.py diff --git a/.gitignore b/.gitignore index ba6122a..efe4ece 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ debian/files debian/python-taiga* debian/python3-taiga* .ruff_cache +.venv +*.egg-link diff --git a/setup.cfg b/setup.cfg index c85baca..9d2f568 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,21 +28,32 @@ install_requires = requests>2.11 python-dateutil>=2.4 pyjwkest>=1.0 -packages = taiga +packages = find: python_requires = >=3.11 setup_requires = setuptools zip_safe = False test_suite = tests +[options.packages.find] +include = + taiga + taiga.* + [options.package_data] * = *.txt, *.rst taiga = *.html *.png *.gif *js *jpg *jpeg *svg *py *mo *po +[options.entry_points] +console_scripts = + taiga-mcp-server = taiga.mcp_server.cli:main + [options.extras_require] docs = sphinx sphinx-rtd-theme +mcp = + fastmcp>=3.0 [sdist] formats = zip diff --git a/taiga/mcp_server/__init__.py b/taiga/mcp_server/__init__.py new file mode 100644 index 0000000..d1fbadf --- /dev/null +++ b/taiga/mcp_server/__init__.py @@ -0,0 +1,7 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +""" +MCP server exposing python-taiga as a set of tools for LLM clients. +""" diff --git a/taiga/mcp_server/auth.py b/taiga/mcp_server/auth.py new file mode 100644 index 0000000..d25fe7f --- /dev/null +++ b/taiga/mcp_server/auth.py @@ -0,0 +1,70 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +from dataclasses import dataclass + +from ..client import TaigaAPI +from ..exceptions import TaigaException + +DEFAULT_HOST = "https://api.taiga.io" +DEFAULT_TOKEN_TYPE = "Bearer" + + +class ConfigError(TaigaException): + """Raised when there isn't enough information to authenticate, or the server wasn't configured.""" + + +@dataclass +class Credentials: + host: str = DEFAULT_HOST + tls_verify: bool = True + token: str | None = None + token_type: str = DEFAULT_TOKEN_TYPE + username: str | None = None + password: str | None = None + + +def build_client(credentials: Credentials) -> TaigaAPI: + """ + Build and authenticate a :class:`TaigaAPI` client from the given credentials. + + A token takes precedence over username/password if both are set. + """ + if credentials.token: + return TaigaAPI( + host=credentials.host, + token=credentials.token, + token_type=credentials.token_type, + tls_verify=credentials.tls_verify, + ) + + if credentials.username and credentials.password: + api = TaigaAPI(host=credentials.host, tls_verify=credentials.tls_verify) + api.auth(credentials.username, credentials.password) + return api + + raise ConfigError("Missing Taiga credentials: provide a token, or both a username and a password.") + + +_credentials: Credentials | None = None +_client: TaigaAPI | None = None + + +def configure(credentials: Credentials) -> None: + """Store the credentials used to lazily build the Taiga client on first use.""" + global _credentials, _client + _credentials = credentials + _client = None + + +def get_client() -> TaigaAPI: + """Return a lazily-built, process-wide :class:`TaigaAPI` client.""" + global _client + if _client is None: + if _credentials is None: + raise ConfigError("The Taiga MCP server has not been configured with any credentials.") + _client = build_client(_credentials) + return _client diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py new file mode 100644 index 0000000..3cff5c5 --- /dev/null +++ b/taiga/mcp_server/cli.py @@ -0,0 +1,75 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +import argparse +import os +import sys + +from .. import __version__ +from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure + + +def _env_bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() not in ("0", "false", "no", "off") + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the ``taiga-mcp-server`` console script.""" + parser = argparse.ArgumentParser( + prog="taiga-mcp-server", + description=( + "Run a Model Context Protocol server exposing python-taiga over stdio. " + "Credentials can be passed as arguments or read from the TAIGA_HOST/TAIGA_TOKEN or " + "TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. " + "Passing --token/--password on the command line can expose them via the process list; " + "prefer the environment variables where possible." + ), + ) + parser.add_argument("--version", action="version", version=f"taiga-mcp-server (python-taiga {__version__})") + parser.add_argument( + "--host", default=os.environ.get("TAIGA_HOST", DEFAULT_HOST), help="Taiga instance host (default: %(default)s)" + ) + parser.add_argument("--token", default=os.environ.get("TAIGA_TOKEN"), help="Taiga auth token") + parser.add_argument( + "--token-type", + default=os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), + help="Type of the auth token (default: %(default)s)", + ) + parser.add_argument("--username", default=os.environ.get("TAIGA_USERNAME"), help="Taiga username") + parser.add_argument("--password", default=os.environ.get("TAIGA_PASSWORD"), help="Taiga password") + tls_group = parser.add_mutually_exclusive_group() + tls_group.add_argument( + "--tls-verify", dest="tls_verify", action="store_true", default=None, help="Verify TLS certificates" + ) + tls_group.add_argument( + "--no-tls-verify", dest="tls_verify", action="store_false", help="Do not verify TLS certificates" + ) + args = parser.parse_args(argv) + + tls_verify = _env_bool("TAIGA_TLS_VERIFY", True) if args.tls_verify is None else args.tls_verify + + configure( + Credentials( + host=args.host, + tls_verify=tls_verify, + token=args.token, + token_type=args.token_type, + username=args.username, + password=args.password, + ) + ) + + from .server import mcp + + mcp.run(transport="stdio") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py new file mode 100644 index 0000000..d6c7ca3 --- /dev/null +++ b/taiga/mcp_server/serialize.py @@ -0,0 +1,27 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +import datetime +from typing import Any + +from ..models.base import InstanceResource + +_SKIPPED_ATTRS = {"requester"} + + +def to_jsonable(value: Any) -> Any: + """Recursively convert python-taiga models into plain JSON-serializable structures.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, (datetime.datetime, datetime.date)): + return value.isoformat() + if isinstance(value, InstanceResource): + return {key: to_jsonable(val) for key, val in vars(value).items() if key not in _SKIPPED_ATTRS} + if isinstance(value, dict): + return {key: to_jsonable(val) for key, val in value.items()} + if isinstance(value, (list, tuple)): + return [to_jsonable(item) for item in value] + return str(value) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py new file mode 100644 index 0000000..094e521 --- /dev/null +++ b/taiga/mcp_server/server.py @@ -0,0 +1,328 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +from typing import Any, Literal + +from fastmcp import FastMCP + +from .auth import get_client +from .serialize import to_jsonable + +mcp = FastMCP( + name="taiga", + instructions=( + "Tools to read and manage Taiga projects: user stories, tasks, issues, epics, " + "milestones and wiki pages. Configure credentials via the TAIGA_HOST/TAIGA_TOKEN " + "or TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. " + "`get_project` returns the full set of statuses/priorities/severities/points ids " + "needed to create or update entities in that project." + ), +) + +_ENTITY_ATTR = { + "user_story": "user_stories", + "task": "tasks", + "issue": "issues", + "epic": "epics", +} + + +def _resolve_project_id(project: str | int) -> int: + if isinstance(project, int) or str(project).isdigit(): + return int(project) + client = get_client() + return client.projects.get_by_slug(str(project)).id + + +@mcp.tool +def whoami() -> dict[str, Any]: + """Return the Taiga user currently authenticated.""" + return to_jsonable(get_client().me()) + + +@mcp.tool +def list_projects(member: int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List projects visible to the authenticated user, optionally filtered by member id.""" + query = dict(filters or {}) + if member is not None: + query["member"] = member + return to_jsonable(get_client().projects.list(**query)) + + +@mcp.tool +def get_project(project: str | int) -> dict[str, Any]: + """Get full project detail by numeric id or slug, including statuses/priorities/severities/points.""" + client = get_client() + if isinstance(project, int) or str(project).isdigit(): + return to_jsonable(client.projects.get(int(project))) + return to_jsonable(client.projects.get_by_slug(str(project))) + + +@mcp.tool +def search(project: str | int, text: str = "") -> dict[str, Any]: + """Search user stories, tasks, issues, epics and wiki pages in a project.""" + client = get_client() + result = client.search(_resolve_project_id(project), text) + return { + "count": result.count, + "user_stories": to_jsonable(result.user_stories), + "tasks": to_jsonable(result.tasks), + "issues": to_jsonable(result.issues), + "epics": to_jsonable(result.epics), + "wikipages": to_jsonable(result.wikipages), + } + + +@mcp.tool +def add_comment( + entity_type: Literal["user_story", "task", "issue", "epic"], id: int, comment: str +) -> dict[str, Any]: # noqa: A002 + """Add a comment to a user story, task, issue or epic.""" + client = get_client() + resource = getattr(client, _ENTITY_ATTR[entity_type]).get(id) + return to_jsonable(resource.add_comment(comment)) + + +# --- User stories ----------------------------------------------------------------- + + +@mcp.tool +def list_user_stories(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List user stories, optionally scoped to a project and/or filtered by extra query params.""" + query = dict(filters or {}) + if project is not None: + query["project"] = _resolve_project_id(project) + return to_jsonable(get_client().user_stories.list(**query)) + + +@mcp.tool +def get_user_story(id: int) -> dict[str, Any]: # noqa: A002 + """Get a user story by id.""" + return to_jsonable(get_client().user_stories.get(id)) + + +@mcp.tool +def create_user_story(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: + """Create a user story. `fields` may set status, points, milestone, description, tags, etc.""" + pid = _resolve_project_id(project) + return to_jsonable(get_client().user_stories.create(pid, subject, **(fields or {}))) + + +@mcp.tool +def update_user_story(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update a user story. `fields` is a dict of the attributes to change.""" + resource = get_client().user_stories.get(id) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool +def delete_user_story(id: int) -> dict[str, str]: # noqa: A002 + """Delete a user story by id.""" + get_client().user_stories.delete(id) + return {"status": "deleted", "id": str(id)} + + +# --- Tasks -------------------------------------------------------------------------- + + +@mcp.tool +def list_tasks( + project: str | int | None = None, user_story: int | None = None, filters: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """List tasks, optionally scoped to a project and/or a user story.""" + query = dict(filters or {}) + if project is not None: + query["project"] = _resolve_project_id(project) + if user_story is not None: + query["user_story"] = user_story + return to_jsonable(get_client().tasks.list(**query)) + + +@mcp.tool +def get_task(id: int) -> dict[str, Any]: # noqa: A002 + """Get a task by id.""" + return to_jsonable(get_client().tasks.get(id)) + + +@mcp.tool +def create_task(project: str | int, subject: str, status: int, fields: dict[str, Any] | None = None) -> dict[str, Any]: + """Create a task. `status` is the numeric task-status id (see get_project). `fields` may set user_story, etc.""" + pid = _resolve_project_id(project) + return to_jsonable(get_client().tasks.create(pid, subject, status, **(fields or {}))) + + +@mcp.tool +def update_task(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update a task. `fields` is a dict of the attributes to change.""" + resource = get_client().tasks.get(id) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool +def delete_task(id: int) -> dict[str, str]: # noqa: A002 + """Delete a task by id.""" + get_client().tasks.delete(id) + return {"status": "deleted", "id": str(id)} + + +# --- Issues --------------------------------------------------------------------------- + + +@mcp.tool +def list_issues(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List issues, optionally scoped to a project.""" + query = dict(filters or {}) + if project is not None: + query["project"] = _resolve_project_id(project) + return to_jsonable(get_client().issues.list(**query)) + + +@mcp.tool +def get_issue(id: int) -> dict[str, Any]: # noqa: A002 + """Get an issue by id.""" + return to_jsonable(get_client().issues.get(id)) + + +@mcp.tool +def create_issue( + project: str | int, + subject: str, + priority: int, + status: int, + issue_type: int, + severity: int, + fields: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Create an issue. `priority`/`status`/`issue_type`/`severity` are numeric ids (see get_project).""" + pid = _resolve_project_id(project) + return to_jsonable( + get_client().issues.create(pid, subject, priority, status, issue_type, severity, **(fields or {})) + ) + + +@mcp.tool +def update_issue(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update an issue. `fields` is a dict of the attributes to change.""" + resource = get_client().issues.get(id) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool +def delete_issue(id: int) -> dict[str, str]: # noqa: A002 + """Delete an issue by id.""" + get_client().issues.delete(id) + return {"status": "deleted", "id": str(id)} + + +# --- Epics ------------------------------------------------------------------------------ + + +@mcp.tool +def list_epics(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List epics, optionally scoped to a project.""" + query = dict(filters or {}) + if project is not None: + query["project"] = _resolve_project_id(project) + return to_jsonable(get_client().epics.list(**query)) + + +@mcp.tool +def get_epic(id: int) -> dict[str, Any]: # noqa: A002 + """Get an epic by id.""" + return to_jsonable(get_client().epics.get(id)) + + +@mcp.tool +def create_epic(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: + """Create an epic.""" + pid = _resolve_project_id(project) + return to_jsonable(get_client().epics.create(pid, subject, **(fields or {}))) + + +@mcp.tool +def update_epic(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update an epic. `fields` is a dict of the attributes to change.""" + resource = get_client().epics.get(id) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool +def delete_epic(id: int) -> dict[str, str]: # noqa: A002 + """Delete an epic by id.""" + get_client().epics.delete(id) + return {"status": "deleted", "id": str(id)} + + +# --- Milestones (sprints) ----------------------------------------------------------------- + + +@mcp.tool +def list_milestones(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List milestones (sprints) of a project.""" + pid = _resolve_project_id(project) + query = dict(filters or {}) + query["project"] = pid + return to_jsonable(get_client().milestones.list(**query)) + + +@mcp.tool +def get_milestone(id: int) -> dict[str, Any]: # noqa: A002 + """Get a milestone by id.""" + return to_jsonable(get_client().milestones.get(id)) + + +@mcp.tool +def create_milestone( + project: str | int, + name: str, + estimated_start: str, + estimated_finish: str, + fields: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Create a milestone. Dates are ISO strings ('YYYY-MM-DD').""" + pid = _resolve_project_id(project) + return to_jsonable(get_client().milestones.create(pid, name, estimated_start, estimated_finish, **(fields or {}))) + + +@mcp.tool +def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 + """Delete a milestone by id.""" + get_client().milestones.delete(id) + return {"status": "deleted", "id": str(id)} + + +# --- Wiki pages ----------------------------------------------------------------------------- + + +@mcp.tool +def list_wiki_pages(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List wiki pages of a project.""" + pid = _resolve_project_id(project) + query = dict(filters or {}) + query["project"] = pid + return to_jsonable(get_client().wikipages.list(**query)) + + +@mcp.tool +def get_wiki_page(id: int) -> dict[str, Any]: # noqa: A002 + """Get a wiki page by id.""" + return to_jsonable(get_client().wikipages.get(id)) + + +@mcp.tool +def create_wiki_page( + project: str | int, slug: str, content: str, fields: dict[str, Any] | None = None +) -> dict[str, Any]: + """Create a wiki page.""" + pid = _resolve_project_id(project) + return to_jsonable(get_client().wikipages.create(pid, slug, content, **(fields or {}))) + + +@mcp.tool +def update_wiki_page(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update a wiki page. `fields` is a dict of the attributes to change.""" + resource = get_client().wikipages.get(id) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) From 841f5cd9b9142880af05f983b7e7352fb236c52e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 8 Aug 2026 00:20:51 +0200 Subject: [PATCH 02/12] docs: add AGENTS.md with instructions for registering the Taiga MCP server Gives coding agents a step-by-step procedure for adding taiga-mcp-server to a user's global (--scope user) Claude Code config: resolving a stable install (pip install --user by default, pipx/uvx as alternatives), collecting credentials safely, the exact claude mcp add invocations, and verification steps. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf9fda9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# Agent instructions + +This file gives coding agents (Claude Code and similar) step-by-step +instructions for tasks specific to this repository. Human-facing docs live in +``README.rst`` and ``docs/``. + +## Registering the Taiga MCP server in the user's global Claude config + +This repo ships an MCP server (`taiga/mcp_server/`) that exposes the Taiga +REST API as tools over stdio, via the `taiga-mcp-server` console script +(installed by the `mcp` extra: `pip install -e .[mcp]` from this repo, or +`pip install python-taiga[mcp]` from PyPI). + +When asked to "add the Taiga MCP server to Claude" / "register taiga-mcp +globally" / "add it to my user-wide config", follow this procedure: + +1. **Confirm before acting.** Registering at user scope changes the user's + global Claude Code config (`~/.claude.json`), applying to every project, + not just this repo. Confirm the target Taiga instance and scope with the + user before running the command, unless they've already given explicit + go-ahead in this conversation. + +2. **Get a stable `taiga-mcp-server` binary.** Don't point the MCP config at + a project-local `.venv` — Claude Code launches MCP server commands without + inheriting an activated venv, and the binary disappears if that venv is + ever recreated. Install it somewhere durable instead. There are several + equally valid ways to do this; pick whichever fits the user's toolchain, + asking if it's unclear, and default to `pip install --user` since it needs + nothing beyond a reasonably modern Python: + ```bash + # default: pip install --user (works with any modern Python/pip) + pip install --user "python-taiga[mcp]" # from PyPI + pip install --user -e ".[mcp]" # from this checkout + + # pipx (isolated venv per tool, one binary on PATH) + pipx install "python-taiga[mcp]" # from PyPI + pipx install --editable ".[mcp]" # from this checkout + + # uvx (no persistent install; uv manages an ephemeral/cached env) + # here the *registered command* becomes `uvx --from "python-taiga[mcp]" taiga-mcp-server` + # instead of a resolved path — see the uvx example in step 4. + ``` + After a `pip --user`/`pipx` install, resolve the resulting path and use it + verbatim in step 4: + ```bash + command -v taiga-mcp-server + ``` + +3. **Collect credentials.** Ask the user for: + - `TAIGA_HOST` — the Taiga site root, e.g. `https://taiga.nephila.it`. + For self-hosted instances this is *not* an `api.` subdomain and has no + `/api` suffix — the client appends `/api/v1` itself. + - Either `TAIGA_TOKEN` (pre-issued API token), or both + `TAIGA_USERNAME` and `TAIGA_PASSWORD`. A token takes precedence if both + are configured. + - Optional: `TAIGA_TOKEN_TYPE` (default `Bearer`), `TAIGA_TLS_VERIFY` + (default `true`). + + Never pass `--token`/`--password` as CLI arguments — they'd be visible in + the process list. Always pass credentials as environment variables. + +4. **Register at user scope** with `claude mcp add`, using `-e` for every + credential env var and the resolved binary (or `uvx` invocation) from + step 2: + ```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_USERNAME= \ + -e TAIGA_PASSWORD= \ + -- /absolute/path/to/taiga-mcp-server + ``` + or, with a token instead of username/password: + ```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_TOKEN= \ + -- /absolute/path/to/taiga-mcp-server + ``` + With `uvx` there's no path to resolve — pass the `uvx` invocation itself + as the command: + ```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_TOKEN= \ + -- uvx --from "python-taiga[mcp]" taiga-mcp-server + ``` + `--scope user` (not `local`/`project`) is what makes it "user-wide" — + available in every project for that user, stored outside this repo. + +5. **Verify** with `claude mcp list` (look for `taiga` ... `✔ Connected`) and + `claude mcp get taiga`. If it fails to connect, re-check the resolved + binary/command from step 2 and that `TAIGA_HOST` is the site root, not an + API subdomain. + +6. **Don't persist secrets in the repo.** Credentials belong only in the + `claude mcp add -e ...` invocation (stored in the user's own + `~/.claude.json`) — never write them into files inside this repository. From 121e97a39280fb284383e01ffb3b6190921df09d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 8 Aug 2026 00:26:59 +0200 Subject: [PATCH 03/12] docs: clarify token vs password guidance in AGENTS.md Stock Taiga has no self-service personal access token feature. The only tokens available are short-lived auth JWTs (24h/8-day on the instance checked) and admin-gated OAuth Application tokens, and this server's CLI has no refresh-token support. Default agent guidance to username/password and only suggest TAIGA_TOKEN when the target instance is verified to offer a durable personal token. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cf9fda9..8543355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,21 @@ globally" / "add it to my user-wide config", follow this procedure: Never pass `--token`/`--password` as CLI arguments — they'd be visible in the process list. Always pass credentials as environment variables. + **Default to username/password over a token, unless the instance has a + real personal-access-token feature.** Stock Taiga (checked against + `https://taiga.nephila.it`) has no self-service PAT: the only tokens it + issues are (a) short-lived JWTs from `POST /api/v1/auth` — on that + instance, a 24h access token / 8-day refresh token — and (b) OAuth-style + "Application" tokens, which require an admin-registered app and a + consent/`auth_code` flow (`client.auth_app()`), not something a regular + user can self-serve. This server's `auth.py`/CLI has no refresh-token + support, so a manually-generated `TAIGA_TOKEN` will just silently stop + working after ~24h with no renewal — worse than username/password, which + re-authenticates fresh on every server start. Only reach for `TAIGA_TOKEN` + when the target instance genuinely offers a durable personal token (e.g. + a Taiga Enterprise/hosted deployment with PAT support) — verify that + before recommending it, don't assume it exists. + 4. **Register at user scope** with `claude mcp add`, using `-e` for every credential env var and the resolved binary (or `uvx` invocation) from step 2: From af3db85ad1f28635f72acfd3abe8c9d75f514c55 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 8 Aug 2026 00:35:39 +0200 Subject: [PATCH 04/12] docs: add Sphinx page for the MCP server Adds docs/mcp.rst covering what the MCP server is, installing the mcp extra (pip install --user / pipx / uvx), the TAIGA_HOST/TAIGA_TOKEN/ TAIGA_USERNAME/TAIGA_PASSWORD/TAIGA_TLS_VERIFY configuration (env vars and equivalent CLI flags), running it standalone, registering it with an MCP client such as Claude Code, the full tool list grouped by entity, and a security note on write-tool blast radius. Wired into the toctree in docs/index.rst. Verified with a clean -W sphinx-build. Also picks up an unrelated AGENTS.md edit (anonymizing the example Taiga host) that was already pending in the working tree. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 10 +-- docs/index.rst | 1 + docs/mcp.rst | 177 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 docs/mcp.rst diff --git a/AGENTS.md b/AGENTS.md index 8543355..53d6f38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ globally" / "add it to my user-wide config", follow this procedure: ``` 3. **Collect credentials.** Ask the user for: - - `TAIGA_HOST` — the Taiga site root, e.g. `https://taiga.nephila.it`. + - `TAIGA_HOST` — the Taiga site root, e.g. `https://my.taiga.com`. For self-hosted instances this is *not* an `api.` subdomain and has no `/api` suffix — the client appends `/api/v1` itself. - Either `TAIGA_TOKEN` (pre-issued API token), or both @@ -61,7 +61,7 @@ globally" / "add it to my user-wide config", follow this procedure: **Default to username/password over a token, unless the instance has a real personal-access-token feature.** Stock Taiga (checked against - `https://taiga.nephila.it`) has no self-service PAT: the only tokens it + `https://my.taiga.com`) has no self-service PAT: the only tokens it issues are (a) short-lived JWTs from `POST /api/v1/auth` — on that instance, a 24h access token / 8-day refresh token — and (b) OAuth-style "Application" tokens, which require an admin-registered app and a @@ -79,7 +79,7 @@ globally" / "add it to my user-wide config", follow this procedure: step 2: ```bash claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_USERNAME= \ -e TAIGA_PASSWORD= \ -- /absolute/path/to/taiga-mcp-server @@ -87,7 +87,7 @@ globally" / "add it to my user-wide config", follow this procedure: or, with a token instead of username/password: ```bash claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ -- /absolute/path/to/taiga-mcp-server ``` @@ -95,7 +95,7 @@ globally" / "add it to my user-wide config", follow this procedure: as the command: ```bash claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://taiga.nephila.it \ + -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ -- uvx --from "python-taiga[mcp]" taiga-mcp-server ``` diff --git a/docs/index.rst b/docs/index.rst index b76c672..04a953f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,6 +10,7 @@ Welcome to python-taiga's documentation! :maxdepth: 3 usage + mcp api models development diff --git a/docs/mcp.rst b/docs/mcp.rst new file mode 100644 index 0000000..763d8e4 --- /dev/null +++ b/docs/mcp.rst @@ -0,0 +1,177 @@ +.. :mcp: + +========== +MCP Server +========== + +Contents: + +python-taiga ships a `Model Context Protocol `_ +(MCP) server that exposes Taiga projects, user stories, tasks, issues, epics, +milestones and wiki pages as tools an LLM-based assistant (Claude, or any +other MCP-compatible client) can call directly, without you writing any glue +code. + +.. note:: The MCP server wraps the same ``TaigaAPI`` documented in + :doc:`the usage guide ` and :doc:`the API reference ` - + if you need to script against Taiga from Python yourself, use + ``TaigaAPI`` directly instead. + +**************** +Installation +**************** + +The server is an optional extra, since it pulls in `fastmcp +`_ as a dependency: + +.. code:: shell + + pip install "python-taiga[mcp]" + +Any of the following also work, depending on your toolchain: + +.. code:: shell + + pip install --user "python-taiga[mcp]" # no virtualenv management needed + pipx install "python-taiga[mcp]" # isolated venv, one command on PATH + uvx --from "python-taiga[mcp]" taiga-mcp-server # no persistent install at all + +Any of these makes a ``taiga-mcp-server`` console script available. + +**************** +Configuration +**************** + +Credentials are read from environment variables, or from equivalent +command-line flags (flags take precedence over the environment): + +.. list-table:: + :header-rows: 1 + :widths: 20 25 55 + + * - Environment variable + - CLI flag + - Meaning + * - ``TAIGA_HOST`` + - ``--host`` + - Taiga instance root, e.g. ``https://taiga.example.com``. Defaults to + ``https://api.taiga.io``. + * - ``TAIGA_TOKEN`` + - ``--token`` + - A pre-issued auth token. Takes precedence over username/password if + both are set. + * - ``TAIGA_TOKEN_TYPE`` + - ``--token-type`` + - Type of the token above. Defaults to ``Bearer``. + * - ``TAIGA_USERNAME`` + - ``--username`` + - Username, used together with the password below. + * - ``TAIGA_PASSWORD`` + - ``--password`` + - Password, exchanged for a session token at startup. + * - ``TAIGA_TLS_VERIFY`` + - ``--tls-verify`` / ``--no-tls-verify`` + - Verify TLS certificates. Defaults to ``true``. + +.. warning:: Prefer the environment variables over the CLI flags for + ``--token``/``--password``: command-line arguments are visible + to other processes on the same machine (e.g. via ``ps``), + environment variables set for the server's own process are not. + +.. note:: Most Taiga instances don't offer a durable personal-access-token + feature - the token obtained from a username/password login is a + short-lived JWT (often expiring within a day), and this server + doesn't refresh it once started. Unless you know your instance + issues long-lived tokens, configure ``TAIGA_USERNAME``/ + ``TAIGA_PASSWORD`` rather than a fixed ``TAIGA_TOKEN`` - the server + re-authenticates fresh every time it starts. + +****************************** +Running the server standalone +****************************** + +.. code:: shell + + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server + +The server speaks MCP over stdio and is meant to be launched by an MCP +client, not used interactively - the command above will sit and wait for a +client to connect over stdin/stdout. + +***************************** +Connecting an MCP client +***************************** + +Any MCP client that supports the stdio transport can launch +``taiga-mcp-server`` as a subprocess. For `Claude Code +`_, register it once and it's +available in every project: + +.. code:: shell + + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://taiga.example.com \ + -e TAIGA_USERNAME=myuser \ + -e TAIGA_PASSWORD=mypassword \ + -- taiga-mcp-server + +``--scope user`` stores the registration in your own Claude configuration, +not in any particular project. Check it went through with: + +.. code:: shell + + claude mcp get taiga + +**************** +Available tools +**************** + +``whoami`` + Return the Taiga user currently authenticated. + +``list_projects`` / ``get_project`` + List projects visible to the user, or fetch one project's full detail + (numeric id or slug) - including the statuses/priorities/severities/points + ids needed to create or update entities in it. + +``search`` + Search user stories, tasks, issues, epics and wiki pages in a project. + +``add_comment`` + Add a comment to a user story, task, issue or epic. + +``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` + Manage user stories. + +``list_tasks``, ``get_task``, ``create_task``, ``update_task``, ``delete_task`` + Manage tasks, optionally scoped to a project and/or a user story. + +``list_issues``, ``get_issue``, ``create_issue``, ``update_issue``, ``delete_issue`` + Manage issues. + +``list_epics``, ``get_epic``, ``create_epic``, ``update_epic``, ``delete_epic`` + Manage epics. + +``list_milestones``, ``get_milestone``, ``create_milestone``, ``delete_milestone`` + Manage milestones (sprints). + +``list_wiki_pages``, ``get_wiki_page``, ``create_wiki_page``, ``update_wiki_page`` + Manage wiki pages. + +.. tip:: Call ``get_project`` first when creating or updating an entity - it + returns every status/priority/severity/points id valid for that + project, which the ``create_*``/``update_*`` tools expect. + +**************** +Security notes +**************** + +The MCP server has the same permissions as the account it authenticates +with, and the create/update/delete tools above are destructive: an assistant +with access to this server can create, modify or delete real data in your +Taiga projects. Review what an MCP client proposes to do before approving +write operations, and consider a dedicated Taiga account with restricted +project membership if you want to limit the blast radius. From 06f82a3e160666fb6ff49f6d67147949cd49afb2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 8 Aug 2026 01:16:16 +0200 Subject: [PATCH 05/12] fix: ensure all tox environments run cleanly - Restore the testenv:docs section (dropped from tox.ini in 923cabc while "docs" stayed in envlist), and add setuptools to its deps so invoke's clean pre-task (python setup.py clean --all) works. - Fix MANIFEST.in: include AGENTS.md and correct the requirements-tests.txt typo to requirements-test.txt, fixing check-manifest failures in the pypi-description env. Co-Authored-By: Claude Sonnet 5 --- MANIFEST.in | 3 ++- changes/14020.feature | 1 + tox.ini | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changes/14020.feature diff --git a/MANIFEST.in b/MANIFEST.in index ee04217..4c7888c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,9 @@ +include AGENTS.md include AUTHORS include LICENSE include README.rst include CONTRIBUTING.rst include HISTORY.rst include requirements.txt -include requirements-tests.txt +include requirements-test.txt recursive-include taiga *.html *.png *.gif *js *jpg *jpeg *svg *py *mo *po diff --git a/changes/14020.feature b/changes/14020.feature new file mode 100644 index 0000000..4d2b979 --- /dev/null +++ b/changes/14020.feature @@ -0,0 +1 @@ +Add MCP server exposing Taiga projects, user stories, tasks, issues, epics, milestones and wiki pages as tools for AI agents diff --git a/tox.ini b/tox.ini index 9b31b0a..29a8455 100644 --- a/tox.ini +++ b/tox.ini @@ -27,6 +27,17 @@ deps = ruff~=0.15.22 skip_install = true +[testenv:docs] +commands = + {envpython} -m invoke docbuild +deps = + invoke + setuptools + sphinx + sphinx-rtd-theme + -r{toxinidir}/requirements.txt +skip_install = true + [testenv:isort] commands = {envpython} -m isort -c --df taiga tests From 578d25b5e327fd743fde7b2a68776e87d3db7bd0 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 23 Aug 2026 19:27:57 +0200 Subject: [PATCH 06/12] feat(mcp): add get_history tool to read comment/change history Install the mcp extra in requirements.txt so fastmcp is available wherever the test suite runs (tox py311-py314 were failing to collect tests/test_mcp_server.py with ModuleNotFoundError: No module named 'fastmcp'). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + changes/{14020.feature => 267.feature} | 0 docs/mcp.rst | 11 + requirements.txt | 2 +- taiga/mcp_server/server.py | 89 +++- tests/test_mcp_server.py | 616 +++++++++++++++++++++++++ tests/test_mcp_server_auth.py | 99 ++++ tests/test_mcp_server_cli.py | 92 ++++ 8 files changed, 895 insertions(+), 15 deletions(-) rename changes/{14020.feature => 267.feature} (100%) create mode 100644 tests/test_mcp_server.py create mode 100644 tests/test_mcp_server_auth.py create mode 100644 tests/test_mcp_server_cli.py diff --git a/.gitignore b/.gitignore index efe4ece..3dff66b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ debian/python3-taiga* .ruff_cache .venv *.egg-link +.superpowers diff --git a/changes/14020.feature b/changes/267.feature similarity index 100% rename from changes/14020.feature rename to changes/267.feature diff --git a/docs/mcp.rst b/docs/mcp.rst index 763d8e4..a81fa05 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -143,6 +143,12 @@ Available tools ``add_comment`` Add a comment to a user story, task, issue or epic. +``get_history`` + Get the full change/comment history of a user story, task, issue, epic or + wiki page. Each entry's `comment` field is empty for plain field-change + events and non-empty for an actual comment; `delete_comment_date` is + non-null if that comment was later deleted. + ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -165,6 +171,11 @@ Available tools returns every status/priority/severity/points id valid for that project, which the ``create_*``/``update_*`` tools expect. +.. tip:: Every ``list_*`` tool is paginated and defaults to page 1 of up to + 100 results. Pass ``page``/``page_size`` in ``filters`` to move + through further pages, and ``order_by`` (e.g. ``-created_date``) to + control ordering - for example to fetch the most recent items first. + **************** Security notes **************** diff --git a/requirements.txt b/requirements.txt index d6e1198..5f6ce98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e . +-e .[mcp] diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 094e521..ea72cc3 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -37,6 +37,23 @@ def _resolve_project_id(project: str | int) -> int: return client.projects.get_by_slug(str(project)).id +DEFAULT_PAGE_SIZE = 100 + + +def _paginated(query: dict[str, Any]) -> dict[str, Any]: + """Default a list query to a single bounded page. + + The underlying client only stops auto-fetching subsequent pages once an explicit + `page` is given — `page_size` alone does not limit it — so a caller that omits + `page` would otherwise silently walk and return the *entire* remote collection, + which for large projects can mean tens of thousands of records in one response. + Pass `page`/`page_size` inside `filters` to move through further pages. + """ + query.setdefault("page", 1) + query.setdefault("page_size", DEFAULT_PAGE_SIZE) + return query + + @mcp.tool def whoami() -> dict[str, Any]: """Return the Taiga user currently authenticated.""" @@ -45,11 +62,15 @@ def whoami() -> dict[str, Any]: @mcp.tool def list_projects(member: int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List projects visible to the authenticated user, optionally filtered by member id.""" + """List projects visible to the authenticated user, optionally filtered by member id. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ query = dict(filters or {}) if member is not None: query["member"] = member - return to_jsonable(get_client().projects.list(**query)) + return to_jsonable(get_client().projects.list(**_paginated(query))) @mcp.tool @@ -86,16 +107,36 @@ def add_comment( return to_jsonable(resource.add_comment(comment)) +_HISTORY_ENTITY_TYPES = ("user_story", "task", "issue", "epic", "wiki") + + +@mcp.tool +def get_history( + entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], id: int # noqa: A002 +) -> list[dict[str, Any]]: + """Get the full change/comment history of a user story, task, issue, epic or wiki page. + + Each entry has a `comment` field (empty string for pure field-change events, non-empty + for an actual comment) and `delete_comment_date` (non-null if the comment was deleted). + """ + client = get_client() + return to_jsonable(getattr(client.history, entity_type).get(id)) + + # --- User stories ----------------------------------------------------------------- @mcp.tool def list_user_stories(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List user stories, optionally scoped to a project and/or filtered by extra query params.""" + """List user stories, optionally scoped to a project and/or filtered by extra query params. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().user_stories.list(**query)) + return to_jsonable(get_client().user_stories.list(**_paginated(query))) @mcp.tool @@ -132,13 +173,17 @@ def delete_user_story(id: int) -> dict[str, str]: # noqa: A002 def list_tasks( project: str | int | None = None, user_story: int | None = None, filters: dict[str, Any] | None = None ) -> list[dict[str, Any]]: - """List tasks, optionally scoped to a project and/or a user story.""" + """List tasks, optionally scoped to a project and/or a user story. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) if user_story is not None: query["user_story"] = user_story - return to_jsonable(get_client().tasks.list(**query)) + return to_jsonable(get_client().tasks.list(**_paginated(query))) @mcp.tool @@ -173,11 +218,15 @@ def delete_task(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool def list_issues(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List issues, optionally scoped to a project.""" + """List issues, optionally scoped to a project. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().issues.list(**query)) + return to_jsonable(get_client().issues.list(**_paginated(query))) @mcp.tool @@ -222,11 +271,15 @@ def delete_issue(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool def list_epics(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List epics, optionally scoped to a project.""" + """List epics, optionally scoped to a project. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().epics.list(**query)) + return to_jsonable(get_client().epics.list(**_paginated(query))) @mcp.tool @@ -261,11 +314,15 @@ def delete_epic(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool def list_milestones(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List milestones (sprints) of a project.""" + """List milestones (sprints) of a project. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ pid = _resolve_project_id(project) query = dict(filters or {}) query["project"] = pid - return to_jsonable(get_client().milestones.list(**query)) + return to_jsonable(get_client().milestones.list(**_paginated(query))) @mcp.tool @@ -299,11 +356,15 @@ def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool def list_wiki_pages(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List wiki pages of a project.""" + """List wiki pages of a project. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + """ pid = _resolve_project_id(project) query = dict(filters or {}) query["project"] = pid - return to_jsonable(get_client().wikipages.list(**query)) + return to_jsonable(get_client().wikipages.list(**_paginated(query))) @mcp.tool diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..96caad2 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from taiga.mcp_server import server + +_HISTORY_ENTRY = { + "user": {"pk": 1, "name": "tester"}, + "created_at": "2026-08-20T10:00:00+0000", + "comment": "hello", + "comment_html": "

hello

", + "delete_comment_date": None, + "type": 1, +} + + +# --- _resolve_project_id ----------------------------------------------------------------- + + +def test_resolve_project_id_with_int(): + assert server._resolve_project_id(42) == 42 + + +def test_resolve_project_id_with_numeric_string(): + assert server._resolve_project_id("42") == 42 + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_project_id_with_slug(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get_by_slug.return_value = MagicMock(id=7) + mock_get_client.return_value = mock_client + + assert server._resolve_project_id("my-project") == 7 + + mock_client.projects.get_by_slug.assert_called_once_with("my-project") + + +# --- _paginated --------------------------------------------------------------------------- + + +def test_paginated_defaults_page_and_page_size(): + assert server._paginated({}) == {"page": 1, "page_size": 100} + + +def test_paginated_preserves_other_keys(): + assert server._paginated({"project": 1}) == {"project": 1, "page": 1, "page_size": 100} + + +def test_paginated_does_not_override_explicit_page(): + assert server._paginated({"page": 3}) == {"page": 3, "page_size": 100} + + +def test_paginated_does_not_override_explicit_page_size(): + assert server._paginated({"page_size": 25}) == {"page": 1, "page_size": 25} + + +# --- whoami / projects / search ---------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_whoami(mock_get_client): + mock_client = MagicMock() + mock_client.me.return_value = {"id": 1, "username": "tester"} + mock_get_client.return_value = mock_client + + assert server.whoami() == {"id": 1, "username": "tester"} + + +@patch("taiga.mcp_server.server.get_client") +def test_list_projects_without_member(mock_get_client): + mock_client = MagicMock() + mock_client.projects.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_projects() + + mock_client.projects.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_projects_with_member(mock_get_client): + mock_client = MagicMock() + mock_client.projects.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_projects(member=9, filters={"is_backlog_activated": True}) + + mock_client.projects.list.assert_called_once_with(is_backlog_activated=True, member=9, page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_list_projects_explicit_pagination_not_overridden(mock_get_client): + mock_client = MagicMock() + mock_client.projects.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_projects(filters={"page": 3, "page_size": 25, "order_by": "-created_date"}) + + mock_client.projects.list.assert_called_once_with(page=3, page_size=25, order_by="-created_date") + + +@patch("taiga.mcp_server.server.get_client") +def test_get_project_by_id(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_project(1) + + mock_client.projects.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_project_by_slug(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get_by_slug.return_value = {"id": 1, "slug": "my-project"} + mock_get_client.return_value = mock_client + + result = server.get_project("my-project") + + mock_client.projects.get_by_slug.assert_called_once_with("my-project") + assert result == {"id": 1, "slug": "my-project"} + + +@patch("taiga.mcp_server.server.get_client") +def test_search(mock_get_client): + mock_client = MagicMock() + mock_result = MagicMock() + mock_result.count = 2 + mock_result.user_stories = [{"id": 1}] + mock_result.tasks = [] + mock_result.issues = [] + mock_result.epics = [] + mock_result.wikipages = [{"id": 2}] + mock_client.search.return_value = mock_result + mock_get_client.return_value = mock_client + + result = server.search(1, "keyword") + + mock_client.search.assert_called_once_with(1, "keyword") + assert result == { + "count": 2, + "user_stories": [{"id": 1}], + "tasks": [], + "issues": [], + "epics": [], + "wikipages": [{"id": 2}], + } + + +# --- add_comment --------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_add_comment_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + for entity_type, attr in server._ENTITY_ATTR.items(): + resource = getattr(mock_client, attr).get.return_value + resource.add_comment.return_value = {"comment": "hello"} + + result = server.add_comment(entity_type, 1, "hello") + + getattr(mock_client, attr).get.assert_called_once_with(1) + resource.add_comment.assert_called_once_with("hello") + assert result == {"comment": "hello"} + + +# --- get_history ----------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_get_history_returns_jsonable_entries(mock_get_client): + mock_client = MagicMock() + mock_client.history.user_story.get.return_value = [_HISTORY_ENTRY] + mock_get_client.return_value = mock_client + + result = server.get_history("user_story", 42) + + mock_client.history.user_story.get.assert_called_once_with(42) + assert result == [_HISTORY_ENTRY] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_history_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + for entity_type in ("user_story", "task", "issue", "epic", "wiki"): + getattr(mock_client.history, entity_type).get.return_value = [] + result = server.get_history(entity_type, 1) + getattr(mock_client.history, entity_type).get.assert_called_once_with(1) + assert result == [] + + +# --- User stories ----------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_no_project(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_user_stories() + + mock_client.user_stories.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_with_project(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_user_stories(project=1, filters={"status": 2}) + + mock_client.user_stories.list.assert_called_once_with(status=2, project=1, page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_get_user_story(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_user_story(1) + + mock_client.user_stories.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_user_story(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.create.return_value = {"id": 1, "subject": "New story"} + mock_get_client.return_value = mock_client + + result = server.create_user_story(1, "New story", fields={"points": {"1": 2}}) + + mock_client.user_stories.create.assert_called_once_with(1, "New story", points={"1": 2}) + assert result == {"id": 1, "subject": "New story"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_user_story(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_client.user_stories.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_user_story(1, {"subject": "Updated"}) + + mock_client.user_stories.get.assert_called_once_with(1) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_user_story(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_user_story(1) + + mock_client.user_stories.delete.assert_called_once_with(1) + assert result == {"status": "deleted", "id": "1"} + + +# --- Tasks ------------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.get_client") +def test_list_tasks_no_filters(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_tasks() + + mock_client.tasks.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_tasks_with_project_and_user_story(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_tasks(project=1, user_story=5) + + mock_client.tasks.list.assert_called_once_with(project=1, user_story=5, page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_get_task(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_task(1) + + mock_client.tasks.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_task(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.create.return_value = {"id": 1, "subject": "New task"} + mock_get_client.return_value = mock_client + + result = server.create_task(1, "New task", 3, fields={"user_story": 2}) + + mock_client.tasks.create.assert_called_once_with(1, "New task", 3, user_story=2) + assert result == {"id": 1, "subject": "New task"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_client.tasks.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_task(1, {"subject": "Updated"}) + + mock_client.tasks.get.assert_called_once_with(1) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_task(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_task(1) + + mock_client.tasks.delete.assert_called_once_with(1) + assert result == {"status": "deleted", "id": "1"} + + +# --- Issues ----------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_list_issues_no_project(mock_get_client): + mock_client = MagicMock() + mock_client.issues.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_issues() + + mock_client.issues.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_issues_with_project(mock_get_client): + mock_client = MagicMock() + mock_client.issues.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_issues(project=1) + + mock_client.issues.list.assert_called_once_with(project=1, page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_list_issues_explicit_pagination_not_overridden(mock_get_client): + mock_client = MagicMock() + mock_client.issues.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_issues(project=1, filters={"page": 1, "page_size": 2, "order_by": "-created_date"}) + + mock_client.issues.list.assert_called_once_with(project=1, page=1, page_size=2, order_by="-created_date") + + +@patch("taiga.mcp_server.server.get_client") +def test_get_issue(mock_get_client): + mock_client = MagicMock() + mock_client.issues.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_issue(1) + + mock_client.issues.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_issue(mock_get_client): + mock_client = MagicMock() + mock_client.issues.create.return_value = {"id": 1, "subject": "New issue"} + mock_get_client.return_value = mock_client + + result = server.create_issue(1, "New issue", 2, 3, 4, 5, fields={"description": "oops"}) + + mock_client.issues.create.assert_called_once_with(1, "New issue", 2, 3, 4, 5, description="oops") + assert result == {"id": 1, "subject": "New issue"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_issue(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_client.issues.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_issue(1, {"subject": "Updated"}) + + mock_client.issues.get.assert_called_once_with(1) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_issue(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_issue(1) + + mock_client.issues.delete.assert_called_once_with(1) + assert result == {"status": "deleted", "id": "1"} + + +# --- Epics ------------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.get_client") +def test_list_epics_no_project(mock_get_client): + mock_client = MagicMock() + mock_client.epics.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_epics() + + mock_client.epics.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_epics_with_project(mock_get_client): + mock_client = MagicMock() + mock_client.epics.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + server.list_epics(project=1) + + mock_client.epics.list.assert_called_once_with(project=1, page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_get_epic(mock_get_client): + mock_client = MagicMock() + mock_client.epics.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_epic(1) + + mock_client.epics.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_epic(mock_get_client): + mock_client = MagicMock() + mock_client.epics.create.return_value = {"id": 1, "subject": "New epic"} + mock_get_client.return_value = mock_client + + result = server.create_epic(1, "New epic") + + mock_client.epics.create.assert_called_once_with(1, "New epic") + assert result == {"id": 1, "subject": "New epic"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_epic(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_client.epics.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_epic(1, {"subject": "Updated"}) + + mock_client.epics.get.assert_called_once_with(1) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_epic(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_epic(1) + + mock_client.epics.delete.assert_called_once_with(1) + assert result == {"status": "deleted", "id": "1"} + + +# --- Milestones ------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_milestones(1, filters={"closed": False}) + + mock_client.milestones.list.assert_called_once_with(closed=False, project=1, page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_milestone(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_milestone(1) + + mock_client.milestones.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_milestone(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.create.return_value = {"id": 1, "name": "Sprint 1"} + mock_get_client.return_value = mock_client + + result = server.create_milestone(1, "Sprint 1", "2026-01-01", "2026-01-15") + + mock_client.milestones.create.assert_called_once_with(1, "Sprint 1", "2026-01-01", "2026-01-15") + assert result == {"id": 1, "name": "Sprint 1"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_milestone(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_milestone(1) + + mock_client.milestones.delete.assert_called_once_with(1) + assert result == {"status": "deleted", "id": "1"} + + +# --- Wiki pages ------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.get_client") +def test_list_wiki_pages(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_wiki_pages(1, filters={"slug": "home"}) + + mock_client.wikipages.list.assert_called_once_with(slug="home", project=1, page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_wiki_page(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + result = server.get_wiki_page(1) + + mock_client.wikipages.get.assert_called_once_with(1) + assert result == {"id": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_wiki_page(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.create.return_value = {"id": 1, "slug": "home"} + mock_get_client.return_value = mock_client + + result = server.create_wiki_page(1, "home", "Welcome") + + mock_client.wikipages.create.assert_called_once_with(1, "home", "Welcome") + assert result == {"id": 1, "slug": "home"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_wiki_page(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "content": "Updated"} + mock_client.wikipages.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_wiki_page(1, {"content": "Updated"}) + + mock_client.wikipages.get.assert_called_once_with(1) + mock_resource.patch.assert_called_once_with(["content"], content="Updated") + assert result == {"id": 1, "content": "Updated"} diff --git a/tests/test_mcp_server_auth.py b/tests/test_mcp_server_auth.py new file mode 100644 index 0000000..8c22a42 --- /dev/null +++ b/tests/test_mcp_server_auth.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from taiga.mcp_server import auth + +# --- build_client ------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth.TaigaAPI") +def test_build_client_with_token(mock_taiga_api): + credentials = auth.Credentials(host="https://example.com", token="tok", token_type="Bearer", tls_verify=False) + + result = auth.build_client(credentials) + + mock_taiga_api.assert_called_once_with( + host="https://example.com", token="tok", token_type="Bearer", tls_verify=False + ) + assert result is mock_taiga_api.return_value + + +@patch("taiga.mcp_server.auth.TaigaAPI") +def test_build_client_prefers_token_over_username_password(mock_taiga_api): + credentials = auth.Credentials(token="tok", username="alice", password="secret") + + auth.build_client(credentials) + + mock_taiga_api.assert_called_once_with( + host=auth.DEFAULT_HOST, token="tok", token_type=auth.DEFAULT_TOKEN_TYPE, tls_verify=True + ) + mock_taiga_api.return_value.auth.assert_not_called() + + +@patch("taiga.mcp_server.auth.TaigaAPI") +def test_build_client_with_username_password(mock_taiga_api): + mock_api = MagicMock() + mock_taiga_api.return_value = mock_api + credentials = auth.Credentials(host="https://example.com", username="alice", password="secret", tls_verify=True) + + result = auth.build_client(credentials) + + mock_taiga_api.assert_called_once_with(host="https://example.com", tls_verify=True) + mock_api.auth.assert_called_once_with("alice", "secret") + assert result is mock_api + + +def test_build_client_without_credentials_raises(): + credentials = auth.Credentials() + + with pytest.raises(auth.ConfigError, match="provide a token"): + auth.build_client(credentials) + + +def test_build_client_with_only_username_raises(): + credentials = auth.Credentials(username="alice") + + with pytest.raises(auth.ConfigError, match="provide a token"): + auth.build_client(credentials) + + +# --- configure ------------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.auth._client", "stale-client") +@patch("taiga.mcp_server.auth._credentials", None) +def test_configure_stores_credentials_and_resets_client(): + credentials = auth.Credentials(token="tok") + + auth.configure(credentials) + + assert auth._credentials is credentials + assert auth._client is None + + +# --- get_client ----------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_get_client_without_configuration_raises(): + with pytest.raises(auth.ConfigError, match="not been configured"): + auth.get_client() + + +@patch("taiga.mcp_server.auth.build_client") +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials") +def test_get_client_builds_once_and_caches(mock_credentials, mock_build_client): + mock_client = MagicMock() + mock_build_client.return_value = mock_client + + first = auth.get_client() + second = auth.get_client() + + assert first is mock_client + assert second is mock_client + mock_build_client.assert_called_once_with(mock_credentials) diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py new file mode 100644 index 0000000..33a3d46 --- /dev/null +++ b/tests/test_mcp_server_cli.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from unittest.mock import patch + +from taiga.mcp_server import cli + +# --- _env_bool ------------------------------------------------------------------------------ + + +def test_env_bool_default_when_unset(): + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("TAIGA_TLS_VERIFY", None) + assert cli._env_bool("TAIGA_TLS_VERIFY", True) is True + assert cli._env_bool("TAIGA_TLS_VERIFY", False) is False + + +def test_env_bool_falsy_values(): + for value in ("0", "false", "No", "OFF", " off "): + with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": value}): + assert cli._env_bool("TAIGA_TLS_VERIFY", True) is False + + +def test_env_bool_truthy_values(): + for value in ("1", "true", "yes", "anything-else"): + with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": value}): + assert cli._env_bool("TAIGA_TLS_VERIFY", False) is True + + +# --- main ----------------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_main_configures_from_token_argv(mock_configure, mock_mcp): + exit_code = cli.main(["--host", "https://example.com", "--token", "tok", "--no-tls-verify"]) + + assert exit_code == 0 + mock_configure.assert_called_once() + credentials = mock_configure.call_args.args[0] + assert credentials.host == "https://example.com" + assert credentials.token == "tok" + assert credentials.tls_verify is False + mock_mcp.run.assert_called_once_with(transport="stdio") + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_main_configures_from_username_password_argv(mock_configure, mock_mcp): + cli.main(["--username", "alice", "--password", "secret", "--tls-verify"]) + + credentials = mock_configure.call_args.args[0] + assert credentials.username == "alice" + assert credentials.password == "secret" + assert credentials.token is None + assert credentials.tls_verify is True + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_main_reads_credentials_from_env(mock_configure, mock_mcp): + env = { + "TAIGA_HOST": "https://env.example.com", + "TAIGA_TOKEN": "env-tok", + "TAIGA_TOKEN_TYPE": "Basic", + } + with patch.dict("os.environ", env): + cli.main([]) + + credentials = mock_configure.call_args.args[0] + assert credentials.host == "https://env.example.com" + assert credentials.token == "env-tok" + assert credentials.token_type == "Basic" + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_main_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): + with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): + cli.main(["--token", "tok"]) + + assert mock_configure.call_args.args[0].tls_verify is False + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_main_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock_mcp): + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("TAIGA_TLS_VERIFY", None) + cli.main(["--token", "tok"]) + + assert mock_configure.call_args.args[0].tls_verify is True From 4c8d4a35ac1ff2d159f22bf11c41263d50b07d15 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 24 Aug 2026 16:34:55 +0200 Subject: [PATCH 07/12] refactor(mcp): use official mcp SDK (mcp~=2.0) instead of fastmcp Replace the third-party fastmcp dependency with mcp.server.mcpserver.MCPServer from the official MCP Python SDK. mcp 2.0 renamed FastMCP to MCPServer (no back-compat alias) and requires the @mcp.tool() call form instead of the bare @mcp.tool decorator. No behavior change: tool signatures, docstrings, and CLI usage are unchanged. Verified against a real mcp~=2.0 install (66 mcp_server tests + a manual stdio smoke test) and via `tox -e py311 -r` (289 tests passing). Co-Authored-By: Claude Sonnet 5 --- docs/mcp.rst | 4 +-- setup.cfg | 2 +- taiga/mcp_server/server.py | 72 +++++++++++++++++++------------------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index a81fa05..ba74340 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -21,8 +21,8 @@ code. Installation **************** -The server is an optional extra, since it pulls in `fastmcp -`_ as a dependency: +The server is an optional extra, since it pulls in the official `MCP Python SDK +`_ (``mcp``) as a dependency: .. code:: shell diff --git a/setup.cfg b/setup.cfg index 9d2f568..2aef4db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,7 +53,7 @@ docs = sphinx sphinx-rtd-theme mcp = - fastmcp>=3.0 + mcp~=2.0 [sdist] formats = zip diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ea72cc3..ee08408 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -6,12 +6,12 @@ from typing import Any, Literal -from fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from .auth import get_client from .serialize import to_jsonable -mcp = FastMCP( +mcp = MCPServer( name="taiga", instructions=( "Tools to read and manage Taiga projects: user stories, tasks, issues, epics, " @@ -54,13 +54,13 @@ def _paginated(query: dict[str, Any]) -> dict[str, Any]: return query -@mcp.tool +@mcp.tool() def whoami() -> dict[str, Any]: """Return the Taiga user currently authenticated.""" return to_jsonable(get_client().me()) -@mcp.tool +@mcp.tool() def list_projects(member: int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List projects visible to the authenticated user, optionally filtered by member id. @@ -73,7 +73,7 @@ def list_projects(member: int | None = None, filters: dict[str, Any] | None = No return to_jsonable(get_client().projects.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_project(project: str | int) -> dict[str, Any]: """Get full project detail by numeric id or slug, including statuses/priorities/severities/points.""" client = get_client() @@ -82,7 +82,7 @@ def get_project(project: str | int) -> dict[str, Any]: return to_jsonable(client.projects.get_by_slug(str(project))) -@mcp.tool +@mcp.tool() def search(project: str | int, text: str = "") -> dict[str, Any]: """Search user stories, tasks, issues, epics and wiki pages in a project.""" client = get_client() @@ -97,7 +97,7 @@ def search(project: str | int, text: str = "") -> dict[str, Any]: } -@mcp.tool +@mcp.tool() def add_comment( entity_type: Literal["user_story", "task", "issue", "epic"], id: int, comment: str ) -> dict[str, Any]: # noqa: A002 @@ -110,7 +110,7 @@ def add_comment( _HISTORY_ENTITY_TYPES = ("user_story", "task", "issue", "epic", "wiki") -@mcp.tool +@mcp.tool() def get_history( entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], id: int # noqa: A002 ) -> list[dict[str, Any]]: @@ -126,7 +126,7 @@ def get_history( # --- User stories ----------------------------------------------------------------- -@mcp.tool +@mcp.tool() def list_user_stories(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List user stories, optionally scoped to a project and/or filtered by extra query params. @@ -139,27 +139,27 @@ def list_user_stories(project: str | int | None = None, filters: dict[str, Any] return to_jsonable(get_client().user_stories.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_user_story(id: int) -> dict[str, Any]: # noqa: A002 """Get a user story by id.""" return to_jsonable(get_client().user_stories.get(id)) -@mcp.tool +@mcp.tool() def create_user_story(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: """Create a user story. `fields` may set status, points, milestone, description, tags, etc.""" pid = _resolve_project_id(project) return to_jsonable(get_client().user_stories.create(pid, subject, **(fields or {}))) -@mcp.tool +@mcp.tool() def update_user_story(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a user story. `fields` is a dict of the attributes to change.""" resource = get_client().user_stories.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) -@mcp.tool +@mcp.tool() def delete_user_story(id: int) -> dict[str, str]: # noqa: A002 """Delete a user story by id.""" get_client().user_stories.delete(id) @@ -169,7 +169,7 @@ def delete_user_story(id: int) -> dict[str, str]: # noqa: A002 # --- Tasks -------------------------------------------------------------------------- -@mcp.tool +@mcp.tool() def list_tasks( project: str | int | None = None, user_story: int | None = None, filters: dict[str, Any] | None = None ) -> list[dict[str, Any]]: @@ -186,27 +186,27 @@ def list_tasks( return to_jsonable(get_client().tasks.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_task(id: int) -> dict[str, Any]: # noqa: A002 """Get a task by id.""" return to_jsonable(get_client().tasks.get(id)) -@mcp.tool +@mcp.tool() def create_task(project: str | int, subject: str, status: int, fields: dict[str, Any] | None = None) -> dict[str, Any]: """Create a task. `status` is the numeric task-status id (see get_project). `fields` may set user_story, etc.""" pid = _resolve_project_id(project) return to_jsonable(get_client().tasks.create(pid, subject, status, **(fields or {}))) -@mcp.tool +@mcp.tool() def update_task(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a task. `fields` is a dict of the attributes to change.""" resource = get_client().tasks.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) -@mcp.tool +@mcp.tool() def delete_task(id: int) -> dict[str, str]: # noqa: A002 """Delete a task by id.""" get_client().tasks.delete(id) @@ -216,7 +216,7 @@ def delete_task(id: int) -> dict[str, str]: # noqa: A002 # --- Issues --------------------------------------------------------------------------- -@mcp.tool +@mcp.tool() def list_issues(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List issues, optionally scoped to a project. @@ -229,13 +229,13 @@ def list_issues(project: str | int | None = None, filters: dict[str, Any] | None return to_jsonable(get_client().issues.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_issue(id: int) -> dict[str, Any]: # noqa: A002 """Get an issue by id.""" return to_jsonable(get_client().issues.get(id)) -@mcp.tool +@mcp.tool() def create_issue( project: str | int, subject: str, @@ -252,14 +252,14 @@ def create_issue( ) -@mcp.tool +@mcp.tool() def update_issue(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update an issue. `fields` is a dict of the attributes to change.""" resource = get_client().issues.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) -@mcp.tool +@mcp.tool() def delete_issue(id: int) -> dict[str, str]: # noqa: A002 """Delete an issue by id.""" get_client().issues.delete(id) @@ -269,7 +269,7 @@ def delete_issue(id: int) -> dict[str, str]: # noqa: A002 # --- Epics ------------------------------------------------------------------------------ -@mcp.tool +@mcp.tool() def list_epics(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List epics, optionally scoped to a project. @@ -282,27 +282,27 @@ def list_epics(project: str | int | None = None, filters: dict[str, Any] | None return to_jsonable(get_client().epics.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_epic(id: int) -> dict[str, Any]: # noqa: A002 """Get an epic by id.""" return to_jsonable(get_client().epics.get(id)) -@mcp.tool +@mcp.tool() def create_epic(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: """Create an epic.""" pid = _resolve_project_id(project) return to_jsonable(get_client().epics.create(pid, subject, **(fields or {}))) -@mcp.tool +@mcp.tool() def update_epic(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update an epic. `fields` is a dict of the attributes to change.""" resource = get_client().epics.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) -@mcp.tool +@mcp.tool() def delete_epic(id: int) -> dict[str, str]: # noqa: A002 """Delete an epic by id.""" get_client().epics.delete(id) @@ -312,7 +312,7 @@ def delete_epic(id: int) -> dict[str, str]: # noqa: A002 # --- Milestones (sprints) ----------------------------------------------------------------- -@mcp.tool +@mcp.tool() def list_milestones(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List milestones (sprints) of a project. @@ -325,13 +325,13 @@ def list_milestones(project: str | int, filters: dict[str, Any] | None = None) - return to_jsonable(get_client().milestones.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_milestone(id: int) -> dict[str, Any]: # noqa: A002 """Get a milestone by id.""" return to_jsonable(get_client().milestones.get(id)) -@mcp.tool +@mcp.tool() def create_milestone( project: str | int, name: str, @@ -344,7 +344,7 @@ def create_milestone( return to_jsonable(get_client().milestones.create(pid, name, estimated_start, estimated_finish, **(fields or {}))) -@mcp.tool +@mcp.tool() def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 """Delete a milestone by id.""" get_client().milestones.delete(id) @@ -354,7 +354,7 @@ def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 # --- Wiki pages ----------------------------------------------------------------------------- -@mcp.tool +@mcp.tool() def list_wiki_pages(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: """List wiki pages of a project. @@ -367,13 +367,13 @@ def list_wiki_pages(project: str | int, filters: dict[str, Any] | None = None) - return to_jsonable(get_client().wikipages.list(**_paginated(query))) -@mcp.tool +@mcp.tool() def get_wiki_page(id: int) -> dict[str, Any]: # noqa: A002 """Get a wiki page by id.""" return to_jsonable(get_client().wikipages.get(id)) -@mcp.tool +@mcp.tool() def create_wiki_page( project: str | int, slug: str, content: str, fields: dict[str, Any] | None = None ) -> dict[str, Any]: @@ -382,7 +382,7 @@ def create_wiki_page( return to_jsonable(get_client().wikipages.create(pid, slug, content, **(fields or {}))) -@mcp.tool +@mcp.tool() def update_wiki_page(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a wiki page. `fields` is a dict of the attributes to change.""" resource = get_client().wikipages.get(id) From 2b28178d284114883273aa39e078421ab5aea1d9 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 24 Aug 2026 16:35:06 +0200 Subject: [PATCH 08/12] docs: log mcp SDK rewrite and add evaluation report Co-Authored-By: Claude Sonnet 5 --- artifacts/activity-log.md | 42 +++++++++++++++++++ .../evaluations/2026-08-24-mcp-sdk-rewrite.md | 26 ++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 artifacts/activity-log.md create mode 100644 artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md diff --git a/artifacts/activity-log.md b/artifacts/activity-log.md new file mode 100644 index 0000000..16d7f28 --- /dev/null +++ b/artifacts/activity-log.md @@ -0,0 +1,42 @@ +# Activity Log + +## 2026-08-24 — Swapped fastmcp for the official mcp SDK in the Taiga MCP server +**What:** Rewrote `taiga/mcp_server/server.py` to build on the official MCP Python +SDK's `MCPServer` (`mcp.server.mcpserver`, `mcp~=2.0`) instead of the third-party +`fastmcp` package; updated the `mcp` extra in `setup.cfg` and the `docs/mcp.rst` +dependency mention accordingly. On `feature/issue-267-add-mcp`, as a follow-up to +the MCP server added earlier on that same branch. +**Why:** User asked to rewrite the MCP server on the official SDK instead of the +`fastmcp` wrapper, specifically pinned to `mcp~=2.0`. +**Decisions:** +- Classified as a *bounded* change (brainstorming skill) — existing flow, small + mechanical diff — so no spec/plan artifact, direct implementation after in-chat + design approval. +- Confirmed by installing `mcp~=2.0` in a scratch venv: mcp 2.0 renamed + `fastmcp.FastMCP`/`mcp.server.fastmcp.FastMCP` to `mcp.server.mcpserver.MCPServer` + (no back-compat alias), and requires the `@mcp.tool()` call form — bare + `@mcp.tool` raises `TypeError` at import time. +- Renamed to `MCPServer` throughout (chose over aliasing to `FastMCP`) to match + upstream naming exactly, per user preference. +- Stayed on the existing `feature/issue-267-add-mcp` branch rather than cutting a + new one — this is a continuation of the same feature, not new scope. +- Left the working tree uncommitted (per chosen commit strategy) pending user + review before splitting into commits. +**Agent usage:** + +| Stage | Agent/skill | Tokens | Time | +|---|---|---|---| +| Review | general-purpose (requesting-code-review) | ~82k | ~4m | +| Review | nephila-core-conventions:code-eval | ~5k | ~2m | +| Review | nephila-core-conventions:doc-sync | ~3k | ~1m | + +**Considered & dropped:** low-level `mcp.server.lowlevel.Server` rewrite (hand-rolled +schemas/dispatch) — rejected as unnecessary boilerplate once the official SDK's +own FastMCP-equivalent (`MCPServer`) covered the same decorator ergonomics. +Aliasing the new class as `FastMCP` to minimize diff size — rejected in favor of +the real name for clarity to future readers. +**Follow-ups:** `docs/mcp.rst` was updated for the dependency description; no other +doc/config files referenced `fastmcp` by name. Optional (not done): an explicit +tool-count/import smoke test for the SDK swap, and a towncrier fragment for the +dependency change (feature is still unreleased on this branch, so not required). +**Refs:** #267. Eval: 87% — artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md diff --git a/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md b/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md new file mode 100644 index 0000000..8d5834c --- /dev/null +++ b/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md @@ -0,0 +1,26 @@ +# Evaluation — mcp-sdk-rewrite + +- **Date:** 2026-08-24 +- **Branch:** feature/issue-267-add-mcp (working tree, uncommitted) +- **Task:** #267 (follow-up: swap `fastmcp` for the official `mcp` SDK, `mcp~=2.0`) +- **Coverage:** partial — scoped to this task's diff only (`setup.cfg`, `taiga/mcp_server/server.py`, 2 files / 37+37 lines). Excludes the rest of the already-committed MCP feature on this branch, which was a separate prior deliverable. + +## Priority findings +- Documentation ≤ 2: `docs/mcp.rst:24-25` still describes `fastmcp` as the pulled-in dependency, contradicting the code now on `mcp~=2.0` — fix is queued in the immediately-following doc-sync step. + +## Scores +| Dimension | Score | Weight | Key evidence | +|---|---|---|---| +| Functionality | 5 | 20 | 66/66 tests pass against real `mcp~=2.0` in a scratch venv; stdio smoke test lists all 34 tools with instructions preserved verbatim. | +| Testing | 4 | 15 | Existing suite exercises every tool function directly and would fail at import if `MCPServer`/decorator form were wrong (reviewer confirmed); no explicit assertion of tool count/import success as a named test. | +| Security | 4 | 15 | No new input handling introduced; diff is import/class-name/decorator-form only (server.py:9,14,57...). | +| Code quality & best practices | 5 | 15 | Mechanical, minimal diff matching stated intent exactly; no stray bare `@mcp.tool` or leftover `fastmcp` refs (verified via grep). | +| Maintainability & flexibility | 5 | 15 | Matches upstream naming (`MCPServer`) rather than aliasing; drops one third-party dependency. | +| Error handling | N/A | 10 | Diff touches no error-handling paths (`auth.py`/`ConfigError` untouched). | +| Documentation | 2 | 10 | `docs/mcp.rst` still names `fastmcp` as the dependency (see priority finding above). | + +## Recommendations +- Documentation: run doc-sync now to update `docs/mcp.rst`'s install-extra description and the `pypi.org/project/fastmcp` link. + +## Total +**87%** — Clean, correctly-verified mechanical swap; the only real gap is a stale doc line already queued for the next step. From c9ef480116326b73648f15c98d251f9574456f4b Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 24 Aug 2026 16:50:44 +0200 Subject: [PATCH 09/12] ci: drop stale-cache-prone restore-keys fallback for the .tox cache The .tox cache key is hashFiles('setup.cfg'), correctly busting the cache whenever dependencies change. But the restore-keys fallback (unhashed prefix) undermines that: on a cache-key miss it restores the most recent .tox env built against an older setup.cfg, and a plain `tox -e` run won't re-resolve dependencies against the new one (tox only reinstalls deps when their own declaration text changes, not when setup.cfg's extras do) - so CI would run tests against stale, possibly-incompatible dependencies. Reproduced locally: this exact mechanism left .tox/py312-314 with the pre-swap mcp==1.29.0 after the fastmcp -> mcp~=2.0 change in setup.cfg, causing a ModuleNotFoundError for mcp.server.mcpserver. Fixed locally with `tox -e -r`; this commit removes the same trap from CI by dropping the restore-keys fallback for the .tox cache in both workflows. The pip cache's restore-keys are left as-is - that one is just a download cache, safe to partially warm. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/lint.yml | 7 +++++-- .github/workflows/test.yml | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eb0fbb0..73a826d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,9 +30,12 @@ jobs: uses: actions/cache@v6 with: path: .tox + # No restore-keys fallback: a partial match would restore a .tox env built + # against an older setup.cfg, whose dependencies tox won't re-resolve on a + # plain run (it only reinstalls deps when their own declaration text changes, + # not when setup.cfg's extras do) - a cache miss should mean a clean install, + # not a stale/broken one. key: ${{ runner.os }}-lint-${{ matrix.toxenv }}-${{ hashFiles('setup.cfg') }} - restore-keys: | - ${{ runner.os }}-lint-${{ matrix.toxenv }}- - name: Install dependencies run: | python -m pip install --upgrade pip setuptools tox>4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdd0373..f90ca61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,9 +26,12 @@ jobs: uses: actions/cache@v6 with: path: .tox + # No restore-keys fallback: a partial match would restore a .tox env built + # against an older setup.cfg, whose dependencies tox won't re-resolve on a + # plain run (it only reinstalls deps when their own declaration text changes, + # not when setup.cfg's extras do) - a cache miss should mean a clean install, + # not a stale/broken one. key: ${{ runner.os }}-tox-${{ format('{{py{0}}}', matrix.python-version) }}-${{ hashFiles('setup.cfg') }} - restore-keys: | - ${{ runner.os }}-tox-${{ format('{{py{0}}}', matrix.python-version) }}- - name: Install dependencies run: | sudo apt-get install gettext From eb5169fa7d88be865a156082e420748922e1ad2e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 24 Aug 2026 16:51:22 +0200 Subject: [PATCH 10/12] chore: add artifacts to manifest exclusion test --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 29a8455..4a2fb8d 100644 --- a/tox.ini +++ b/tox.ini @@ -108,6 +108,7 @@ ignore = tasks.py tests/** debian/** + artifacts/** *.mo ignore-bad-ideas = *.mo From cb66914d08787335010cccd1a0ce4c37cdfd8eda Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 11:59:17 +0200 Subject: [PATCH 11/12] refactor(mcp): resolve user_story/task/issue/epic by ref, not id User-provided numbers (e.g. extracted from a Taiga URL like .../issues/45634) are per-project refs, not database ids. Make get_/update_/delete_{user_story,task,issue,epic} and add_comment take project+ref as the primary lookup, resolving through Project's get_*_by_ref() endpoints. get_history resolves ref->id the same way for those four entity types; wiki pages have no ref in Taiga, so entity_type="wiki" keeps taking a literal id and no project. Add *_by_id counterparts (get_issue_by_id, update_task_by_id, delete_epic_by_id, add_comment_by_id, get_history_by_id, ...) as a secondary, documented-as-non-default path for callers that already hold the raw database id. Milestones and wiki pages are unchanged - Taiga has no ref concept for either. Rewrote tests/test_mcp_server.py for every changed and added tool. Documented the ref/id distinction and primary/secondary tools in docs/mcp.rst. --- docs/mcp.rst | 30 +++- taiga/mcp_server/server.py | 220 ++++++++++++++++++++--- tests/test_mcp_server.py | 350 +++++++++++++++++++++++++++++++++++-- 3 files changed, 551 insertions(+), 49 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index ba74340..dd2b926 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -140,14 +140,18 @@ Available tools ``search`` Search user stories, tasks, issues, epics and wiki pages in a project. -``add_comment`` - Add a comment to a user story, task, issue or epic. +``add_comment`` / ``add_comment_by_id`` + Add a comment to a user story, task, issue or epic, identified by + ``project`` + ``ref`` (primary) or by database ``id`` (secondary, see + below). -``get_history`` +``get_history`` / ``get_history_by_id`` Get the full change/comment history of a user story, task, issue, epic or wiki page. Each entry's `comment` field is empty for plain field-change events and non-empty for an actual comment; `delete_comment_date` is - non-null if that comment was later deleted. + non-null if that comment was later deleted. Wiki pages have no ref number + in Taiga, so for ``entity_type="wiki"`` pass the page's database id as + ``ref`` and omit ``project``. ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -161,6 +165,24 @@ Available tools ``list_epics``, ``get_epic``, ``create_epic``, ``update_epic``, ``delete_epic`` Manage epics. +.. important:: ``get_user_story``/``get_task``/``get_issue``/``get_epic`` and + their ``update_*``/``delete_*`` counterparts take a ``project`` (id + or slug) and a ``ref`` - the per-project sequential number Taiga + shows in its UI and URLs (e.g. the ``45634`` in + ``.../issues/45634``). That ref is **not** the database id used + internally for updates/deletes - it's only unique within a project, + so it must be resolved together with ``project``. This is the + primary, recommended way to address an entity, since numbers a user + pastes from a Taiga URL or mentions in conversation are almost + always refs. + + Each of these tools also has a ``_by_id`` counterpart (e.g. + ``get_issue_by_id``, ``update_task_by_id``, ``delete_epic_by_id``, + ``add_comment_by_id``) that takes the raw database ``id`` instead. + These are a secondary, non-default lookup path - use them only when + you already hold the database id (for example from a prior tool + response), not a ref. + ``list_milestones``, ``get_milestone``, ``create_milestone``, ``delete_milestone`` Manage milestones (sprints). diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ee08408..89901e8 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -29,6 +29,13 @@ "epic": "epics", } +_REF_METHOD = { + "user_story": "get_userstory_by_ref", + "task": "get_task_by_ref", + "issue": "get_issue_by_ref", + "epic": "get_epic_by_ref", +} + def _resolve_project_id(project: str | int) -> int: if isinstance(project, int) or str(project).isdigit(): @@ -37,6 +44,29 @@ def _resolve_project_id(project: str | int) -> int: return client.projects.get_by_slug(str(project)).id +def _resolve_project(project: str | int) -> Any: + """Fetch the full Project resource. + + Ref-based lookups need the project's id *and* slug, so (unlike + `_resolve_project_id`) this always fetches the project even when given a + numeric id. + """ + client = get_client() + if isinstance(project, int) or str(project).isdigit(): + return client.projects.get(int(project)) + return client.projects.get_by_slug(str(project)) + + +def _get_by_ref(entity_type: str, project: str | int, ref: int) -> Any: + """Resolve a user_story/task/issue/epic to its resource via its per-project ref number. + + `ref` is the sequential number Taiga shows per project - e.g. the 45634 in + `.../issues/45634` - not the database id used internally for update/delete. + """ + proj = _resolve_project(project) + return getattr(proj, _REF_METHOD[entity_type])(ref) + + DEFAULT_PAGE_SIZE = 100 @@ -99,9 +129,22 @@ def search(project: str | int, text: str = "") -> dict[str, Any]: @mcp.tool() def add_comment( + entity_type: Literal["user_story", "task", "issue", "epic"], project: str | int, ref: int, comment: str +) -> dict[str, Any]: + """Add a comment to a user story, task, issue or epic identified by its per-project ref number.""" + resource = _get_by_ref(entity_type, project, ref) + return to_jsonable(resource.add_comment(comment)) + + +@mcp.tool() +def add_comment_by_id( entity_type: Literal["user_story", "task", "issue", "epic"], id: int, comment: str ) -> dict[str, Any]: # noqa: A002 - """Add a comment to a user story, task, issue or epic.""" + """Add a comment by database id. + + Secondary lookup: prefer `add_comment` with a project + ref (the number shown in the + Taiga UI/URL). Use this only when you already hold the raw database id. + """ client = get_client() resource = getattr(client, _ENTITY_ATTR[entity_type]).get(id) return to_jsonable(resource.add_comment(comment)) @@ -112,13 +155,38 @@ def add_comment( @mcp.tool() def get_history( - entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], id: int # noqa: A002 + entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], + project: str | int | None, + ref: int, ) -> list[dict[str, Any]]: """Get the full change/comment history of a user story, task, issue, epic or wiki page. + For entity_type in user_story/task/issue/epic, identify the entity by its per-project + `ref` number (the one shown in the Taiga UI/URL) plus `project`. Wiki pages have no ref + number in Taiga - for entity_type="wiki", pass the page's database id as `ref` and omit + `project`. + Each entry has a `comment` field (empty string for pure field-change events, non-empty for an actual comment) and `delete_comment_date` (non-null if the comment was deleted). """ + if entity_type != "wiki" and project is None: + raise ValueError("project is required unless entity_type is 'wiki'") + client = get_client() + if entity_type == "wiki": + return to_jsonable(client.history.wiki.get(ref)) + resource = _get_by_ref(entity_type, project, ref) + return to_jsonable(getattr(client.history, entity_type).get(resource.id)) + + +@mcp.tool() +def get_history_by_id( + entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], id: int # noqa: A002 +) -> list[dict[str, Any]]: + """Get history by database id. + + Secondary lookup: prefer `get_history` with a project + ref (the number shown in the + Taiga UI/URL). Use this only when you already hold the raw database id. + """ client = get_client() return to_jsonable(getattr(client.history, entity_type).get(id)) @@ -140,8 +208,18 @@ def list_user_stories(project: str | int | None = None, filters: dict[str, Any] @mcp.tool() -def get_user_story(id: int) -> dict[str, Any]: # noqa: A002 - """Get a user story by id.""" +def get_user_story(project: str | int, ref: int) -> dict[str, Any]: + """Get a user story by its per-project ref number (the number shown in the Taiga UI/URL).""" + return to_jsonable(_get_by_ref("user_story", project, ref)) + + +@mcp.tool() +def get_user_story_by_id(id: int) -> dict[str, Any]: # noqa: A002 + """Get a user story by its database id. + + Secondary lookup: prefer `get_user_story` with a project + ref. Use this only when you + already hold the raw database id, not the ref shown in the Taiga UI/URL. + """ return to_jsonable(get_client().user_stories.get(id)) @@ -153,15 +231,30 @@ def create_user_story(project: str | int, subject: str, fields: dict[str, Any] | @mcp.tool() -def update_user_story(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update a user story. `fields` is a dict of the attributes to change.""" +def update_user_story(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: + """Update a user story identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + resource = _get_by_ref("user_story", project, ref) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool() +def update_user_story_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update a user story by its database id. Secondary lookup - prefer `update_user_story` with a project + ref.""" resource = get_client().user_stories.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) @mcp.tool() -def delete_user_story(id: int) -> dict[str, str]: # noqa: A002 - """Delete a user story by id.""" +def delete_user_story(project: str | int, ref: int) -> dict[str, str]: + """Delete a user story identified by its per-project ref number.""" + resource = _get_by_ref("user_story", project, ref) + resource.delete() + return {"status": "deleted", "ref": str(ref)} + + +@mcp.tool() +def delete_user_story_by_id(id: int) -> dict[str, str]: # noqa: A002 + """Delete a user story by its database id. Secondary lookup - prefer `delete_user_story` with a project + ref.""" get_client().user_stories.delete(id) return {"status": "deleted", "id": str(id)} @@ -187,8 +280,18 @@ def list_tasks( @mcp.tool() -def get_task(id: int) -> dict[str, Any]: # noqa: A002 - """Get a task by id.""" +def get_task(project: str | int, ref: int) -> dict[str, Any]: + """Get a task by its per-project ref number (the number shown in the Taiga UI/URL).""" + return to_jsonable(_get_by_ref("task", project, ref)) + + +@mcp.tool() +def get_task_by_id(id: int) -> dict[str, Any]: # noqa: A002 + """Get a task by its database id. + + Secondary lookup: prefer `get_task` with a project + ref. Use this only when you + already hold the raw database id, not the ref shown in the Taiga UI/URL. + """ return to_jsonable(get_client().tasks.get(id)) @@ -200,15 +303,30 @@ def create_task(project: str | int, subject: str, status: int, fields: dict[str, @mcp.tool() -def update_task(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update a task. `fields` is a dict of the attributes to change.""" +def update_task(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: + """Update a task identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + resource = _get_by_ref("task", project, ref) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool() +def update_task_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update a task by its database id. Secondary lookup - prefer `update_task` with a project + ref.""" resource = get_client().tasks.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) @mcp.tool() -def delete_task(id: int) -> dict[str, str]: # noqa: A002 - """Delete a task by id.""" +def delete_task(project: str | int, ref: int) -> dict[str, str]: + """Delete a task identified by its per-project ref number.""" + resource = _get_by_ref("task", project, ref) + resource.delete() + return {"status": "deleted", "ref": str(ref)} + + +@mcp.tool() +def delete_task_by_id(id: int) -> dict[str, str]: # noqa: A002 + """Delete a task by its database id. Secondary lookup - prefer `delete_task` with a project + ref.""" get_client().tasks.delete(id) return {"status": "deleted", "id": str(id)} @@ -230,8 +348,18 @@ def list_issues(project: str | int | None = None, filters: dict[str, Any] | None @mcp.tool() -def get_issue(id: int) -> dict[str, Any]: # noqa: A002 - """Get an issue by id.""" +def get_issue(project: str | int, ref: int) -> dict[str, Any]: + """Get an issue by its per-project ref number (the number shown in the Taiga UI/URL, e.g. .../issues/45634).""" + return to_jsonable(_get_by_ref("issue", project, ref)) + + +@mcp.tool() +def get_issue_by_id(id: int) -> dict[str, Any]: # noqa: A002 + """Get an issue by its database id. + + Secondary lookup: prefer `get_issue` with a project + ref. Use this only when you + already hold the raw database id, not the ref shown in the Taiga UI/URL. + """ return to_jsonable(get_client().issues.get(id)) @@ -253,15 +381,30 @@ def create_issue( @mcp.tool() -def update_issue(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update an issue. `fields` is a dict of the attributes to change.""" +def update_issue(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: + """Update an issue identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + resource = _get_by_ref("issue", project, ref) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool() +def update_issue_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update an issue by its database id. Secondary lookup - prefer `update_issue` with a project + ref.""" resource = get_client().issues.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) @mcp.tool() -def delete_issue(id: int) -> dict[str, str]: # noqa: A002 - """Delete an issue by id.""" +def delete_issue(project: str | int, ref: int) -> dict[str, str]: + """Delete an issue identified by its per-project ref number.""" + resource = _get_by_ref("issue", project, ref) + resource.delete() + return {"status": "deleted", "ref": str(ref)} + + +@mcp.tool() +def delete_issue_by_id(id: int) -> dict[str, str]: # noqa: A002 + """Delete an issue by its database id. Secondary lookup - prefer `delete_issue` with a project + ref.""" get_client().issues.delete(id) return {"status": "deleted", "id": str(id)} @@ -283,8 +426,18 @@ def list_epics(project: str | int | None = None, filters: dict[str, Any] | None @mcp.tool() -def get_epic(id: int) -> dict[str, Any]: # noqa: A002 - """Get an epic by id.""" +def get_epic(project: str | int, ref: int) -> dict[str, Any]: + """Get an epic by its per-project ref number (the number shown in the Taiga UI/URL).""" + return to_jsonable(_get_by_ref("epic", project, ref)) + + +@mcp.tool() +def get_epic_by_id(id: int) -> dict[str, Any]: # noqa: A002 + """Get an epic by its database id. + + Secondary lookup: prefer `get_epic` with a project + ref. Use this only when you + already hold the raw database id, not the ref shown in the Taiga UI/URL. + """ return to_jsonable(get_client().epics.get(id)) @@ -296,15 +449,30 @@ def create_epic(project: str | int, subject: str, fields: dict[str, Any] | None @mcp.tool() -def update_epic(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update an epic. `fields` is a dict of the attributes to change.""" +def update_epic(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: + """Update an epic identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + resource = _get_by_ref("epic", project, ref) + return to_jsonable(resource.patch(list(fields.keys()), **fields)) + + +@mcp.tool() +def update_epic_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 + """Update an epic by its database id. Secondary lookup - prefer `update_epic` with a project + ref.""" resource = get_client().epics.get(id) return to_jsonable(resource.patch(list(fields.keys()), **fields)) @mcp.tool() -def delete_epic(id: int) -> dict[str, str]: # noqa: A002 - """Delete an epic by id.""" +def delete_epic(project: str | int, ref: int) -> dict[str, str]: + """Delete an epic identified by its per-project ref number.""" + resource = _get_by_ref("epic", project, ref) + resource.delete() + return {"status": "deleted", "ref": str(ref)} + + +@mcp.tool() +def delete_epic_by_id(id: int) -> dict[str, str]: # noqa: A002 + """Delete an epic by its database id. Secondary lookup - prefer `delete_epic` with a project + ref.""" get_client().epics.delete(id) return {"status": "deleted", "id": str(id)} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 96caad2..b275a1d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch +import pytest + from taiga.mcp_server import server _HISTORY_ENTRY = { @@ -36,6 +38,67 @@ def test_resolve_project_id_with_slug(mock_get_client): mock_client.projects.get_by_slug.assert_called_once_with("my-project") +# --- _resolve_project --------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_project_with_int(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock(id=42) + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + + result = server._resolve_project(42) + + mock_client.projects.get.assert_called_once_with(42) + assert result is mock_project + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_project_with_numeric_string(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock(id=42) + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + + result = server._resolve_project("42") + + mock_client.projects.get.assert_called_once_with(42) + assert result is mock_project + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_project_with_slug(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock(id=7, slug="my-project") + mock_client.projects.get_by_slug.return_value = mock_project + mock_get_client.return_value = mock_client + + result = server._resolve_project("my-project") + + mock_client.projects.get_by_slug.assert_called_once_with("my-project") + assert result is mock_project + + +# --- _get_by_ref ---------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_get_by_ref_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + + for entity_type, method_name in server._REF_METHOD.items(): + getattr(mock_project, method_name).return_value = {"ref": 45634} + + result = server._get_by_ref(entity_type, 1, 45634) + + getattr(mock_project, method_name).assert_called_once_with(45634) + assert result == {"ref": 45634} + + # --- _paginated --------------------------------------------------------------------------- @@ -156,6 +219,24 @@ def test_search(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_add_comment_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + + for entity_type, method_name in server._REF_METHOD.items(): + resource = getattr(mock_project, method_name).return_value + resource.add_comment.return_value = {"comment": "hello"} + + result = server.add_comment(entity_type, 1, 45634, "hello") + + getattr(mock_project, method_name).assert_called_once_with(45634) + resource.add_comment.assert_called_once_with("hello") + assert result == {"comment": "hello"} + + +@patch("taiga.mcp_server.server.get_client") +def test_add_comment_by_id_routes_every_entity_type(mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -163,7 +244,7 @@ def test_add_comment_routes_every_entity_type(mock_get_client): resource = getattr(mock_client, attr).get.return_value resource.add_comment.return_value = {"comment": "hello"} - result = server.add_comment(entity_type, 1, "hello") + result = server.add_comment_by_id(entity_type, 1, "hello") getattr(mock_client, attr).get.assert_called_once_with(1) resource.add_comment.assert_called_once_with("hello") @@ -174,25 +255,67 @@ def test_add_comment_routes_every_entity_type(mock_get_client): @patch("taiga.mcp_server.server.get_client") -def test_get_history_returns_jsonable_entries(mock_get_client): +def test_get_history_resolves_ref_for_non_wiki_types(mock_get_client): mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + resolved = MagicMock(id=99) + mock_project.get_userstory_by_ref.return_value = resolved mock_client.history.user_story.get.return_value = [_HISTORY_ENTRY] mock_get_client.return_value = mock_client - result = server.get_history("user_story", 42) + result = server.get_history("user_story", 1, 45634) - mock_client.history.user_story.get.assert_called_once_with(42) + mock_project.get_userstory_by_ref.assert_called_once_with(45634) + mock_client.history.user_story.get.assert_called_once_with(99) assert result == [_HISTORY_ENTRY] @patch("taiga.mcp_server.server.get_client") -def test_get_history_routes_every_entity_type(mock_get_client): +def test_get_history_routes_every_ref_entity_type(mock_get_client): mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project mock_get_client.return_value = mock_client - for entity_type in ("user_story", "task", "issue", "epic", "wiki"): + for entity_type, method_name in server._REF_METHOD.items(): + resolved = MagicMock(id=1) + getattr(mock_project, method_name).return_value = resolved getattr(mock_client.history, entity_type).get.return_value = [] - result = server.get_history(entity_type, 1) + + result = server.get_history(entity_type, 1, 45634) + + getattr(mock_project, method_name).assert_called_once_with(45634) + getattr(mock_client.history, entity_type).get.assert_called_once_with(1) + assert result == [] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_history_wiki_uses_literal_id(mock_get_client): + mock_client = MagicMock() + mock_client.history.wiki.get.return_value = [_HISTORY_ENTRY] + mock_get_client.return_value = mock_client + + result = server.get_history("wiki", None, 1) + + mock_client.history.wiki.get.assert_called_once_with(1) + mock_client.projects.get.assert_not_called() + assert result == [_HISTORY_ENTRY] + + +def test_get_history_requires_project_for_non_wiki(): + with pytest.raises(ValueError, match="project"): + server.get_history("issue", None, 1) + + +@patch("taiga.mcp_server.server.get_client") +def test_get_history_by_id_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + for entity_type in server._HISTORY_ENTITY_TYPES: + getattr(mock_client.history, entity_type).get.return_value = [] + result = server.get_history_by_id(entity_type, 1) getattr(mock_client.history, entity_type).get.assert_called_once_with(1) assert result == [] @@ -225,11 +348,26 @@ def test_list_user_stories_with_project(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_get_user_story(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_userstory_by_ref.return_value = {"id": 1, "ref": 45634} + mock_get_client.return_value = mock_client + + result = server.get_user_story(1, 45634) + + mock_client.projects.get.assert_called_once_with(1) + mock_project.get_userstory_by_ref.assert_called_once_with(45634) + assert result == {"id": 1, "ref": 45634} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_user_story_by_id(mock_get_client): mock_client = MagicMock() mock_client.user_stories.get.return_value = {"id": 1} mock_get_client.return_value = mock_client - result = server.get_user_story(1) + result = server.get_user_story_by_id(1) mock_client.user_stories.get.assert_called_once_with(1) assert result == {"id": 1} @@ -249,13 +387,30 @@ def test_create_user_story(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_user_story(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_project.get_userstory_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_user_story(1, 45634, {"subject": "Updated"}) + + mock_project.get_userstory_by_ref.assert_called_once_with(45634) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_user_story_by_id(mock_get_client): mock_client = MagicMock() mock_resource = MagicMock() mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} mock_client.user_stories.get.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.update_user_story(1, {"subject": "Updated"}) + result = server.update_user_story_by_id(1, {"subject": "Updated"}) mock_client.user_stories.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") @@ -265,9 +420,25 @@ def test_update_user_story(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_delete_user_story(mock_get_client): mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_project.get_userstory_by_ref.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.delete_user_story(1) + result = server.delete_user_story(1, 45634) + + mock_project.get_userstory_by_ref.assert_called_once_with(45634) + mock_resource.delete.assert_called_once_with() + assert result == {"status": "deleted", "ref": "45634"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_user_story_by_id(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_user_story_by_id(1) mock_client.user_stories.delete.assert_called_once_with(1) assert result == {"status": "deleted", "id": "1"} @@ -301,11 +472,25 @@ def test_list_tasks_with_project_and_user_story(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_get_task(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_task_by_ref.return_value = {"id": 1, "ref": 45634} + mock_get_client.return_value = mock_client + + result = server.get_task(1, 45634) + + mock_project.get_task_by_ref.assert_called_once_with(45634) + assert result == {"id": 1, "ref": 45634} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_task_by_id(mock_get_client): mock_client = MagicMock() mock_client.tasks.get.return_value = {"id": 1} mock_get_client.return_value = mock_client - result = server.get_task(1) + result = server.get_task_by_id(1) mock_client.tasks.get.assert_called_once_with(1) assert result == {"id": 1} @@ -325,13 +510,30 @@ def test_create_task(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_task(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_task(1, 45634, {"subject": "Updated"}) + + mock_project.get_task_by_ref.assert_called_once_with(45634) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_by_id(mock_get_client): mock_client = MagicMock() mock_resource = MagicMock() mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} mock_client.tasks.get.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.update_task(1, {"subject": "Updated"}) + result = server.update_task_by_id(1, {"subject": "Updated"}) mock_client.tasks.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") @@ -341,9 +543,25 @@ def test_update_task(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_delete_task(mock_get_client): mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_project.get_task_by_ref.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.delete_task(1) + result = server.delete_task(1, 45634) + + mock_project.get_task_by_ref.assert_called_once_with(45634) + mock_resource.delete.assert_called_once_with() + assert result == {"status": "deleted", "ref": "45634"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_task_by_id(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = server.delete_task_by_id(1) mock_client.tasks.delete.assert_called_once_with(1) assert result == {"status": "deleted", "id": "1"} @@ -388,11 +606,25 @@ def test_list_issues_explicit_pagination_not_overridden(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_get_issue(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_issue_by_ref.return_value = {"id": 1, "ref": 45634} + mock_get_client.return_value = mock_client + + result = server.get_issue(1, 45634) + + mock_project.get_issue_by_ref.assert_called_once_with(45634) + assert result == {"id": 1, "ref": 45634} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_issue_by_id(mock_get_client): mock_client = MagicMock() mock_client.issues.get.return_value = {"id": 1} mock_get_client.return_value = mock_client - result = server.get_issue(1) + result = server.get_issue_by_id(1) mock_client.issues.get.assert_called_once_with(1) assert result == {"id": 1} @@ -412,13 +644,30 @@ def test_create_issue(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_issue(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_project.get_issue_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_issue(1, 45634, {"subject": "Updated"}) + + mock_project.get_issue_by_ref.assert_called_once_with(45634) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_issue_by_id(mock_get_client): mock_client = MagicMock() mock_resource = MagicMock() mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} mock_client.issues.get.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.update_issue(1, {"subject": "Updated"}) + result = server.update_issue_by_id(1, {"subject": "Updated"}) mock_client.issues.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") @@ -427,10 +676,26 @@ def test_update_issue(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_delete_issue(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_project.get_issue_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.delete_issue(1, 45634) + + mock_project.get_issue_by_ref.assert_called_once_with(45634) + mock_resource.delete.assert_called_once_with() + assert result == {"status": "deleted", "ref": "45634"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_issue_by_id(mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client - result = server.delete_issue(1) + result = server.delete_issue_by_id(1) mock_client.issues.delete.assert_called_once_with(1) assert result == {"status": "deleted", "id": "1"} @@ -464,11 +729,25 @@ def test_list_epics_with_project(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_get_epic(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_epic_by_ref.return_value = {"id": 1, "ref": 45634} + mock_get_client.return_value = mock_client + + result = server.get_epic(1, 45634) + + mock_project.get_epic_by_ref.assert_called_once_with(45634) + assert result == {"id": 1, "ref": 45634} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_epic_by_id(mock_get_client): mock_client = MagicMock() mock_client.epics.get.return_value = {"id": 1} mock_get_client.return_value = mock_client - result = server.get_epic(1) + result = server.get_epic_by_id(1) mock_client.epics.get.assert_called_once_with(1) assert result == {"id": 1} @@ -488,13 +767,30 @@ def test_create_epic(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_epic(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_project.get_epic_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_epic(1, 45634, {"subject": "Updated"}) + + mock_project.get_epic_by_ref.assert_called_once_with(45634) + mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + assert result == {"id": 1, "subject": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_epic_by_id(mock_get_client): mock_client = MagicMock() mock_resource = MagicMock() mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} mock_client.epics.get.return_value = mock_resource mock_get_client.return_value = mock_client - result = server.update_epic(1, {"subject": "Updated"}) + result = server.update_epic_by_id(1, {"subject": "Updated"}) mock_client.epics.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") @@ -503,10 +799,26 @@ def test_update_epic(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_delete_epic(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock() + mock_project.get_epic_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.delete_epic(1, 45634) + + mock_project.get_epic_by_ref.assert_called_once_with(45634) + mock_resource.delete.assert_called_once_with() + assert result == {"status": "deleted", "ref": "45634"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_epic_by_id(mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client - result = server.delete_epic(1) + result = server.delete_epic_by_id(1) mock_client.epics.delete.assert_called_once_with(1) assert result == {"status": "deleted", "id": "1"} From 482fadf09ab81590aedb7141d1eb59013ed2e6ce Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 12:46:09 +0200 Subject: [PATCH 12/12] fix(mcp): close pagination bypass, stop serializing stale patch/comment state Address 4 review comments on PR #268 (commit cb66914): - _paginated(): filters was forwarded straight into ListResource.list(), so pagination=False (a client-control kwarg) or an explicit but falsy page/page_size (None, 0) bypassed the page-1/page_size-100 bound and could trigger an unbounded full-collection fetch. Strip `pagination` and normalize falsy page/page_size instead of dict.setdefault(). - update_*/update_*_by_id (9 call sites) and update_wiki_page: InstanceResource.patch() only refreshes `version` on the local object, not the fields the server actually applied - serializing the patched object directly returned stale pre-update values. Re-fetch the resource after patching before serializing it. - add_comment/add_comment_by_id: CommentableResource.add_comment() delegates to update(), which has the same staleness issue and never carries the comment itself (comments are history entries, not a resource field). Return an explicit {"status": "commented", ...} acknowledgement instead of serializing the stale resource. Updated tests/test_mcp_server.py for all of the above. --- taiga/mcp_server/server.py | 70 +++++++++++++++++++++++-------- tests/test_mcp_server.py | 84 +++++++++++++++++++++++--------------- 2 files changed, 103 insertions(+), 51 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 89901e8..f1eba6d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -78,9 +78,17 @@ def _paginated(query: dict[str, Any]) -> dict[str, Any]: `page` would otherwise silently walk and return the *entire* remote collection, which for large projects can mean tens of thousands of records in one response. Pass `page`/`page_size` inside `filters` to move through further pages. + + `filters` is forwarded straight into `ListResource.list()`, so a caller could + otherwise defeat this bound by passing `pagination=False` (a client-control kwarg, + stripped here) or an explicit but falsy `page`/`page_size` (e.g. `None` or `0`, + normalized here rather than left as-is like `dict.setdefault` would). """ - query.setdefault("page", 1) - query.setdefault("page_size", DEFAULT_PAGE_SIZE) + query.pop("pagination", None) + if not query.get("page"): + query["page"] = 1 + if not query.get("page_size"): + query["page_size"] = DEFAULT_PAGE_SIZE return query @@ -132,8 +140,12 @@ def add_comment( entity_type: Literal["user_story", "task", "issue", "epic"], project: str | int, ref: int, comment: str ) -> dict[str, Any]: """Add a comment to a user story, task, issue or epic identified by its per-project ref number.""" + # CommentableResource.add_comment() delegates to update(), which returns the stale + # pre-comment resource with only `version` refreshed - not the comment itself - so it + # must not be serialized as the result; return an explicit acknowledgement instead. resource = _get_by_ref(entity_type, project, ref) - return to_jsonable(resource.add_comment(comment)) + resource.add_comment(comment) + return {"status": "commented", "ref": str(ref), "comment": comment} @mcp.tool() @@ -147,7 +159,8 @@ def add_comment_by_id( """ client = get_client() resource = getattr(client, _ENTITY_ATTR[entity_type]).get(id) - return to_jsonable(resource.add_comment(comment)) + resource.add_comment(comment) + return {"status": "commented", "id": str(id), "comment": comment} _HISTORY_ENTITY_TYPES = ("user_story", "task", "issue", "epic", "wiki") @@ -233,15 +246,21 @@ def create_user_story(project: str | int, subject: str, fields: dict[str, Any] | @mcp.tool() def update_user_story(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: """Update a user story identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + # InstanceResource.patch() only refreshes `version` on the local object, not the other + # fields the server actually applied, so the result must be re-fetched, not serialized + # from the patched object itself. resource = _get_by_ref("user_story", project, ref) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(get_client().user_stories.get(resource.id)) @mcp.tool() def update_user_story_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a user story by its database id. Secondary lookup - prefer `update_user_story` with a project + ref.""" - resource = get_client().user_stories.get(id) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + client = get_client() + resource = client.user_stories.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.user_stories.get(id)) @mcp.tool() @@ -305,15 +324,19 @@ def create_task(project: str | int, subject: str, status: int, fields: dict[str, @mcp.tool() def update_task(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: """Update a task identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. resource = _get_by_ref("task", project, ref) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(get_client().tasks.get(resource.id)) @mcp.tool() def update_task_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a task by its database id. Secondary lookup - prefer `update_task` with a project + ref.""" - resource = get_client().tasks.get(id) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + client = get_client() + resource = client.tasks.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.tasks.get(id)) @mcp.tool() @@ -383,15 +406,19 @@ def create_issue( @mcp.tool() def update_issue(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: """Update an issue identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. resource = _get_by_ref("issue", project, ref) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(get_client().issues.get(resource.id)) @mcp.tool() def update_issue_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update an issue by its database id. Secondary lookup - prefer `update_issue` with a project + ref.""" - resource = get_client().issues.get(id) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + client = get_client() + resource = client.issues.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.issues.get(id)) @mcp.tool() @@ -451,15 +478,19 @@ def create_epic(project: str | int, subject: str, fields: dict[str, Any] | None @mcp.tool() def update_epic(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: """Update an epic identified by its per-project ref number. `fields` is a dict of the attributes to change.""" + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. resource = _get_by_ref("epic", project, ref) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(get_client().epics.get(resource.id)) @mcp.tool() def update_epic_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update an epic by its database id. Secondary lookup - prefer `update_epic` with a project + ref.""" - resource = get_client().epics.get(id) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + client = get_client() + resource = client.epics.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.epics.get(id)) @mcp.tool() @@ -553,5 +584,8 @@ def create_wiki_page( @mcp.tool() def update_wiki_page(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 """Update a wiki page. `fields` is a dict of the attributes to change.""" - resource = get_client().wikipages.get(id) - return to_jsonable(resource.patch(list(fields.keys()), **fields)) + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. + client = get_client() + resource = client.wikipages.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.wikipages.get(id)) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b275a1d..30918c3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -118,6 +118,22 @@ def test_paginated_does_not_override_explicit_page_size(): assert server._paginated({"page_size": 25}) == {"page": 1, "page_size": 25} +def test_paginated_strips_pagination_override(): + # `pagination=False` is a ListResource.list() kwarg that disables the bound entirely - + # a caller must not be able to pass it through `filters`. + assert server._paginated({"pagination": False}) == {"page": 1, "page_size": 100} + + +def test_paginated_normalizes_falsy_page(): + assert server._paginated({"page": None}) == {"page": 1, "page_size": 100} + assert server._paginated({"page": 0}) == {"page": 1, "page_size": 100} + + +def test_paginated_normalizes_falsy_page_size(): + assert server._paginated({"page_size": None}) == {"page": 1, "page_size": 100} + assert server._paginated({"page_size": 0}) == {"page": 1, "page_size": 100} + + # --- whoami / projects / search ---------------------------------------------------------- @@ -219,6 +235,9 @@ def test_search(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_add_comment_routes_every_entity_type(mock_get_client): + # CommentableResource.add_comment() delegates to update(), which returns the stale + # pre-comment resource (only `version` is refreshed) - not the new comment. The tool + # must not serialize that stale resource; it returns an explicit acknowledgement. mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project @@ -226,13 +245,12 @@ def test_add_comment_routes_every_entity_type(mock_get_client): for entity_type, method_name in server._REF_METHOD.items(): resource = getattr(mock_project, method_name).return_value - resource.add_comment.return_value = {"comment": "hello"} result = server.add_comment(entity_type, 1, 45634, "hello") getattr(mock_project, method_name).assert_called_once_with(45634) resource.add_comment.assert_called_once_with("hello") - assert result == {"comment": "hello"} + assert result == {"status": "commented", "ref": "45634", "comment": "hello"} @patch("taiga.mcp_server.server.get_client") @@ -242,13 +260,12 @@ def test_add_comment_by_id_routes_every_entity_type(mock_get_client): for entity_type, attr in server._ENTITY_ATTR.items(): resource = getattr(mock_client, attr).get.return_value - resource.add_comment.return_value = {"comment": "hello"} result = server.add_comment_by_id(entity_type, 1, "hello") getattr(mock_client, attr).get.assert_called_once_with(1) resource.add_comment.assert_called_once_with("hello") - assert result == {"comment": "hello"} + assert result == {"status": "commented", "id": "1", "comment": "hello"} # --- get_history ----------------------------------------------------------------------- @@ -387,33 +404,35 @@ def test_create_user_story(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_user_story(mock_get_client): + # InstanceResource.patch() only refreshes `version` on the local object, not the other + # fields the server actually applied - the tool must re-fetch before serializing. mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_resource = MagicMock(id=1) mock_project.get_userstory_by_ref.return_value = mock_resource + mock_client.user_stories.get.return_value = {"id": 1, "subject": "Updated"} mock_get_client.return_value = mock_client result = server.update_user_story(1, 45634, {"subject": "Updated"}) mock_project.get_userstory_by_ref.assert_called_once_with(45634) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.user_stories.get.assert_called_once_with(1) assert result == {"id": 1, "subject": "Updated"} @patch("taiga.mcp_server.server.get_client") def test_update_user_story_by_id(mock_get_client): mock_client = MagicMock() - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} - mock_client.user_stories.get.return_value = mock_resource + mock_resource = MagicMock(id=1) + mock_client.user_stories.get.side_effect = [mock_resource, {"id": 1, "subject": "Updated"}] mock_get_client.return_value = mock_client result = server.update_user_story_by_id(1, {"subject": "Updated"}) - mock_client.user_stories.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.user_stories.get.assert_has_calls([call(1), call(1)]) assert result == {"id": 1, "subject": "Updated"} @@ -513,30 +532,30 @@ def test_update_task(mock_get_client): mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_resource = MagicMock(id=1) mock_project.get_task_by_ref.return_value = mock_resource + mock_client.tasks.get.return_value = {"id": 1, "subject": "Updated"} mock_get_client.return_value = mock_client result = server.update_task(1, 45634, {"subject": "Updated"}) mock_project.get_task_by_ref.assert_called_once_with(45634) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.tasks.get.assert_called_once_with(1) assert result == {"id": 1, "subject": "Updated"} @patch("taiga.mcp_server.server.get_client") def test_update_task_by_id(mock_get_client): mock_client = MagicMock() - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} - mock_client.tasks.get.return_value = mock_resource + mock_resource = MagicMock(id=1) + mock_client.tasks.get.side_effect = [mock_resource, {"id": 1, "subject": "Updated"}] mock_get_client.return_value = mock_client result = server.update_task_by_id(1, {"subject": "Updated"}) - mock_client.tasks.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.tasks.get.assert_has_calls([call(1), call(1)]) assert result == {"id": 1, "subject": "Updated"} @@ -647,30 +666,30 @@ def test_update_issue(mock_get_client): mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_resource = MagicMock(id=1) mock_project.get_issue_by_ref.return_value = mock_resource + mock_client.issues.get.return_value = {"id": 1, "subject": "Updated"} mock_get_client.return_value = mock_client result = server.update_issue(1, 45634, {"subject": "Updated"}) mock_project.get_issue_by_ref.assert_called_once_with(45634) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.issues.get.assert_called_once_with(1) assert result == {"id": 1, "subject": "Updated"} @patch("taiga.mcp_server.server.get_client") def test_update_issue_by_id(mock_get_client): mock_client = MagicMock() - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} - mock_client.issues.get.return_value = mock_resource + mock_resource = MagicMock(id=1) + mock_client.issues.get.side_effect = [mock_resource, {"id": 1, "subject": "Updated"}] mock_get_client.return_value = mock_client result = server.update_issue_by_id(1, {"subject": "Updated"}) - mock_client.issues.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.issues.get.assert_has_calls([call(1), call(1)]) assert result == {"id": 1, "subject": "Updated"} @@ -770,30 +789,30 @@ def test_update_epic(mock_get_client): mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} + mock_resource = MagicMock(id=1) mock_project.get_epic_by_ref.return_value = mock_resource + mock_client.epics.get.return_value = {"id": 1, "subject": "Updated"} mock_get_client.return_value = mock_client result = server.update_epic(1, 45634, {"subject": "Updated"}) mock_project.get_epic_by_ref.assert_called_once_with(45634) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.epics.get.assert_called_once_with(1) assert result == {"id": 1, "subject": "Updated"} @patch("taiga.mcp_server.server.get_client") def test_update_epic_by_id(mock_get_client): mock_client = MagicMock() - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "subject": "Updated"} - mock_client.epics.get.return_value = mock_resource + mock_resource = MagicMock(id=1) + mock_client.epics.get.side_effect = [mock_resource, {"id": 1, "subject": "Updated"}] mock_get_client.return_value = mock_client result = server.update_epic_by_id(1, {"subject": "Updated"}) - mock_client.epics.get.assert_called_once_with(1) mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_client.epics.get.assert_has_calls([call(1), call(1)]) assert result == {"id": 1, "subject": "Updated"} @@ -916,13 +935,12 @@ def test_create_wiki_page(mock_get_client): @patch("taiga.mcp_server.server.get_client") def test_update_wiki_page(mock_get_client): mock_client = MagicMock() - mock_resource = MagicMock() - mock_resource.patch.return_value = {"id": 1, "content": "Updated"} - mock_client.wikipages.get.return_value = mock_resource + mock_resource = MagicMock(id=1) + mock_client.wikipages.get.side_effect = [mock_resource, {"id": 1, "content": "Updated"}] mock_get_client.return_value = mock_client result = server.update_wiki_page(1, {"content": "Updated"}) - mock_client.wikipages.get.assert_called_once_with(1) + mock_client.wikipages.get.assert_has_calls([call(1), call(1)]) mock_resource.patch.assert_called_once_with(["content"], content="Updated") assert result == {"id": 1, "content": "Updated"}