From 7d2828dfb17b9db492fd73aaec948eb99df42f87 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 20 Jul 2026 17:19:00 +0200 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 75b476af729b541f1987a0167d07329d4ab5b048 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 11:59:17 +0200 Subject: [PATCH 11/32] 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 237e2f4b09b38ffbccb1d878a231958afd1d75d5 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 12:46:09 +0200 Subject: [PATCH 12/32] 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"} From c29e4b430b0b79013eca5881b42122a64a12d68f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:25:15 +0200 Subject: [PATCH 13/32] docs(mcp): add design for CLI parity with ring-mcp-server Analysis comparing this repo's hand-implemented, SDK-driven MCP tool set against ring-mcp-server's dynamic OpenAPI-generated one, and an approved design closing the CLI/invocation-method gap: add serve, list-tools, and call --json subcommands to taiga-mcp-server via a Typer rewrite of cli.py, without touching tool architecture. Breaking change flagged: bare 'taiga-mcp-server' will require an explicit 'serve' subcommand going forward. GitHub issue: 14039 --- .../specs/2026-08-31-mcp-cli-parity-design.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 artifacts/specs/2026-08-31-mcp-cli-parity-design.md diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md new file mode 100644 index 0000000..60a962d --- /dev/null +++ b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md @@ -0,0 +1,263 @@ +# Design: CLI parity between python-taiga's MCP server and ring-mcp-server + +Date: 2026-08-31 +Status: Approved (design phase). Implementation plan to follow in this repo. +Origin: analysis and design were done from the `ring-mcp-server` repository +(comparing this project's `taiga/mcp_server/` against `ring-mcp-server`'s +CLI), then handed off and moved here since this is where the actual +implementation belongs. Taiga: us-14039. GitHub issue: 14039. + +## Context + +`ring-mcp-server` (github.com/nephila/ring_mcp) and this repo's +`taiga/mcp_server/` package are both MCP servers for Nephila tooling, but +architecturally opposite by design: + +- **ring-mcp-server**: generates its entire MCP tool set dynamically at + startup from a bundled OpenAPI 3.0 spec (`ring_mcp/spec.py`, + `ring_mcp/tools.py`). Tool names are the spec's `operationId`s verbatim + (dashes → underscores). This is intentional to that project and out of + scope here. +- **python-taiga** (this repo): hand-implements each of its ~45 (48 + including cross-cutting ones) Taiga operations as an individually + authored `@mcp.tool()`-decorated function in `taiga/mcp_server/server.py`, + using the official MCP SDK's `MCPServer` (`mcp.server.mcpserver`, + `mcp==2.0.0`). Tool name/description/input schema are all derived by the + SDK from the function signature and docstring. **This architecture must + not change** — that was an explicit constraint on this design. + +What differs today, and what this design closes, is the **CLI surface**: +ring-mcp-server exposes its full tool set through a small, fixed set of +generic CLI subcommands usable directly from a shell without an MCP client +(`serve`, `list-tools`, `call --json`, `fetch-token`). +`taiga-mcp-server` today does exactly one thing — start the MCP stdio +server — with no way to list or invoke a tool from a shell at all. + +## Goal + +Give `taiga-mcp-server` the same **CLI verb shape** and **invocation +method** as `ring-mcp-server`, without touching this repo's core +architecture (each Taiga operation stays a hand-written `@mcp.tool()` +function; no dynamic generation is introduced). + +## Non-goals (explicitly out of scope, confirmed during design) + +- **No renaming of existing tools.** The ~45 tool functions + (`list_user_stories`, `get_issue`, `create_task`, etc.) and their + parameters/`ref`-vs-`_by_id` addressing convention are untouched. Parity + is scoped to the CLI verbs and the JSON-blob invocation method only, not + to reshaping tool names toward ring's OpenAPI-operationId-identity style. +- **No `fetch-token` equivalent.** `auth.build_client()` already resolves + username/password to a session token internally and lazily on first tool + call. Taiga JWTs are typically short-lived (per this repo's own + `AGENTS.md`), so a separately printed, exportable token doesn't carry its + weight the way ring's DRF token does. Skipped. +- **No change to `taiga/mcp_server/server.py`'s tool bodies, `auth.py`'s + credential-resolution logic, or `serialize.py`.** This design touches only + `taiga/mcp_server/cli.py` (rewritten) and its tests/docs. + +## Breaking change (must be called out prominently) + +Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio +server. **This design makes `serve` an explicit, required subcommand** — +bare invocation becomes a Typer usage error. This was a deliberate choice +(matching ring's shape exactly) made during design, not a byproduct. + +Impact: every existing MCP client config that invokes the binary with no +arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` +and `uvx --from "python-taiga[mcp]" taiga-mcp-server` examples currently +documented in this repo's own `AGENTS.md`) breaks and must add ` serve`. +This needs: + +- A major-version bump per this repo's own versioning/release mechanism + (`bump-my-version` per one of the branch names seen in `git branch -a` — + confirm exact tool/config during plan execution). +- A prominent breaking-change note in the CHANGELOG/release notes. +- Updated examples in `docs/mcp.rst` and `AGENTS.md` (see "Docs" below). + +## Design + +### 1. CLI structure (Typer) + +Rewrite `taiga/mcp_server/cli.py` from `argparse` to **Typer** (a new +dependency for this repo, chosen deliberately for implementation-style +consistency with ring-mcp-server over keeping argparse, per explicit design +decision — trade-off: one new runtime dependency plus rewriting the existing +flag-parsing logic). + +Three subcommands: + +``` +taiga-mcp-server serve + [--host HOST] [--token TOKEN] [--token-type TYPE] + [--username USER] [--password PASS] [--tls-verify/--no-tls-verify] + + Same auth flags, same env-var fallback (TAIGA_HOST/TAIGA_TOKEN/ + TAIGA_TOKEN_TYPE/TAIGA_USERNAME/TAIGA_PASSWORD/TAIGA_TLS_VERIFY), same + precedence (flag > env > default) as today's argparse implementation. + Calls auth.configure(...), then mcp.run(transport="stdio"). Behavior is + identical to today's default flow — only the verb is new. + +taiga-mcp-server list-tools [--verbose/-v] + [same auth flags as serve, for consistency — list-tools itself never + calls get_client(), so credentials aren't actually required to run it, + but auth.configure() is still invoked for a uniform command surface] + + Default: one line per tool, "name\tdescription", sorted by name. + --verbose: also pretty-prints each tool's JSON input schema. + +taiga-mcp-server call --json/-j '' + [same auth flags as serve — required here since most tools call + get_client()] + + Parses --json (default "{}") as the arguments dict, invokes the named + tool in-process, prints the JSON result to stdout, or an error to + stderr with exit code 1. +``` + +Each subcommand keeps its own copy of the auth option set (via a shared +Typer callback or small options dataclass) rather than global +pre-subcommand flags — idiomatic Typer, and keeps `serve`'s flag behavior +byte-for-byte compatible with today aside from requiring the verb. + +### 2. Invocation mechanics (verified against the installed SDK) + +`mcp.server.mcpserver.MCPServer` (`mcp==2.0.0`) is a distinct, purpose-built +class — not a `FastMCP` alias — exposing async in-process APIs confirmed by +direct inspection/execution against this repo's real `mcp` object +(`taiga.mcp_server.server.mcp`, using the `.tox/py313` env, which has the +`[mcp]` extra installed), with no live MCP client/transport round trip +required: + +```python +async def list_tools(self) -> list[mcp_types.Tool]: ... +async def call_tool(self, name: str, arguments: dict[str, Any], + context=None) -> CallToolResult | InputRequiredResult: ... +``` + +**`list-tools`:** +```python +tools = asyncio.run(mcp.list_tools()) +for t in sorted(tools, key=lambda t: t.name): + dumped = t.model_dump(by_alias=True, exclude_none=True) + print(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + print(json.dumps(dumped["inputSchema"], indent=2)) +``` +`model_dump(by_alias=True, exclude_none=True)` yields the wire-shaped keys +(`name`, `description`, `inputSchema`, `outputSchema`) exactly as an MCP +`ListTools` response would. Verified live: 48 tools registered today, e.g. +```json +{"name": "whoami", "description": "Return the Taiga user currently authenticated.", + "inputSchema": {"properties": {}, "title": "whoamiArguments", "type": "object"}, + "outputSchema": {"additionalProperties": true, "title": "whoamiDictOutput", "type": "object"}} +``` + +**`call`:** +```python +arguments = json.loads(json_str) # malformed JSON -> caught separately, see below +try: + result = asyncio.run(mcp.call_tool(tool_name, arguments)) +except ToolError as e: + ... # see error table below +else: + payload = result.structured_content if result.structured_content is not None else result.content + json.dump(payload, sys.stdout, indent=2, default=str) +``` + +`auth.configure(...)` runs before `asyncio.run(...)`, exactly as `serve` +does today, so `get_client()` inside tool bodies resolves credentials the +same way it does under a real MCP client. + +### 3. Error handling & output contract + +Mirrors ring's stderr-message-plus-`typer.Exit(1)` contract, mapped onto +this repo's actual failure shapes (all verified by direct execution against +the real `mcp` object during design): + +| Failure | Detection | stderr message | +|---|---|---| +| Malformed `--json` | `json.JSONDecodeError` | `Invalid JSON in --json: {exc}` | +| Unknown tool name | `ToolError` message starts with `"Unknown tool: "` | printed as-is | +| Argument validation failure | `ToolError` with `e.__cause__` a `pydantic_core.ValidationError` | `Invalid arguments for {tool_name}: {cause}` | +| Tool raised an application exception (`ConfigError`, `TaigaRestException`, etc.) | `ToolError` with any other `e.__cause__` | `Error calling {tool_name}: {cause}` (fallback to `str(e)` if `__cause__` is `None`) | +| Missing/invalid credentials at `serve`/`call` startup | `ConfigError` from `auth.build_client()` | `{exc}` (message already clear per `auth.py`) | +| Anything from `mcp.shared.exceptions.MCPError` (unwrapped by `call_tool()` per the SDK's own re-raise) | caught for safety even though not expected in normal use | same generic "Error calling {tool_name}: {cause}" formatting | + +All of the above: message to stderr, `raise typer.Exit(1)`. + +Verified failure shapes, captured live against the real `mcp` object +(against `whoami`, an unknown tool, and `get_project` with a missing +required argument): + +```python +await mcp.call_tool("whoami", {}) +# ToolError: "Error executing tool whoami: The Taiga MCP server has not +# been configured with any credentials." +# e.__cause__ -> ConfigError(...) + +await mcp.call_tool("this_tool_does_not_exist", {}) +# ToolError: "Unknown tool: this_tool_does_not_exist" + +await mcp.call_tool("get_project", {}) # missing required "project" arg +# ToolError: "Error executing tool get_project: 1 validation error for +# get_projectArguments ..." +# type(e.__cause__) -> pydantic_core.ValidationError +``` + +On success: `call` prefers `result.structured_content` (populated for every +tool here, since they all return dicts/lists via `to_jsonable()`), falling +back to `result.content` only if `structured_content` is `None`. Verified +live (with `get_client()` stubbed, since no live Taiga credentials were +available during design): +```python +result = await mcp.call_tool("whoami", {}) +# type(result) -> mcp_types._types.CallToolResult +# result.structured_content -> {'id': 1, 'username': 'demo'} +# result.is_error -> False +``` +Written via `json.dump(payload, sys.stdout, indent=2, default=str)`. + +### 4. Testing (scope; exact fixtures/layout to be confirmed against this +repo's existing `tests/` conventions when the plan is written) + +- **`serve`**: port existing argparse-flag-precedence tests to Typer's + `CliRunner`; add a test asserting bare invocation (no subcommand) now + exits non-zero instead of serving. +- **`list-tools`**: all tool names present, sorted; `--verbose` includes + each tool's `inputSchema`; runs without any credentials configured (never + calls `get_client()`). +- **`call`**: success path (stub/monkeypatch `get_client()`, assert stdout + JSON matches the tool's return value); malformed `--json`; unknown tool + name; missing required argument; tool-internal exception (e.g. + unconfigured-credentials `ConfigError`) — each asserting the exact stderr + message and exit code 1. +- No live network/Taiga server needed anywhere — everything runs in-process + against `mcp` with `get_client`/`TaigaAPI` stubbed, as verified during + design. + +### 5. Docs & migration + +- `docs/mcp.rst`: update every example showing bare `taiga-mcp-server` to + `taiga-mcp-server serve`; add a new subsection documenting `list-tools` + and `call`, styled after ring-mcp-server's own usage docs. +- `AGENTS.md`: update the two `claude mcp add ... -- taiga-mcp-server` / + `-- uvx --from "python-taiga[mcp]" taiga-mcp-server` examples (step 4) to + append ` serve`. +- CHANGELOG/release-notes mechanism for this repo (confirm exact convention + during plan execution) documenting the breaking change. + +## Open items for the implementation plan (not blocking this design) + +- Confirm this repo's exact test directory layout/fixtures for + `taiga/mcp_server/` before writing test cases. +- Confirm this repo's exact versioning/changelog mechanism for recording + the breaking change (a `chore/issue-140-switch-to-bump-my-version` branch + was seen in `git branch -a`, suggesting `bump-my-version` — verify). +- Confirm the Typer dependency is added correctly to `setup.cfg`'s `[mcp]` + extras (alongside the existing `mcp~=2.0` pin). +- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on + `feature/issue-267-add-mcp` (where `taiga/mcp_server/` currently lives, + unmerged to `master`) rather than `master` itself, since the package + doesn't exist on `master` yet. Rebase onto `master` once issue-267 merges, + before this branch is itself merged. From a97679a752e4cfa3664d3a71001627fe1c530018 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:31:38 +0200 Subject: [PATCH 14/32] docs(mcp): add implementation plan for CLI parity with ring-mcp-server Step-by-step TDD plan executing the approved design: Typer rewrite of cli.py adding serve/list-tools/call subcommands, docs updates, and towncrier changelog fragments. GitHub issue: 14039 --- artifacts/plans/2026-08-31-mcp-cli-parity.md | 762 +++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 artifacts/plans/2026-08-31-mcp-cli-parity.md diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md new file mode 100644 index 0000000..82e33df --- /dev/null +++ b/artifacts/plans/2026-08-31-mcp-cli-parity.md @@ -0,0 +1,762 @@ +# Taiga MCP CLI Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `taiga-mcp-server` the same CLI verb shape as `ring-mcp-server` — `serve`, `list-tools [--verbose]`, `call --json ''` — without touching any of the ~45 hand-implemented `@mcp.tool()` functions in `taiga/mcp_server/server.py`. + +**Architecture:** Rewrite `taiga/mcp_server/cli.py` from `argparse` to `typer`. `serve` preserves today's behavior byte-for-byte, now behind an explicit subcommand instead of the bare invocation. `list-tools` and `call` invoke the already-constructed `mcp` object in-process via `asyncio.run(mcp.list_tools())` / `asyncio.run(mcp.call_tool(name, arguments))` — no subprocess, no live MCP client round trip. + +**Tech Stack:** Python 3.11–3.14, `typer` (new dependency, `>=0.12.0` to match `ring-mcp-server`'s own floor), `mcp==2.0.0` (already pinned via the `[mcp]` extra), `pytest` + `typer.testing.CliRunner`. + +**Spec:** `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` + +## Global Constraints + +- Do not modify `taiga/mcp_server/server.py`, `taiga/mcp_server/auth.py`, or `taiga/mcp_server/serialize.py` — tool bodies, credential resolution, and serialization stay exactly as they are (spec §Non-goals). +- Do not rename any of the ~45 existing tool functions or their parameters (spec §Non-goals). +- `serve`'s auth flags/env-var precedence (flag > env > default) must remain identical to today's argparse behavior (spec §1). +- Console script stays `taiga-mcp-server = taiga.mcp_server.cli:main` in `setup.cfg` — no entry-point path change, `main()` just becomes a thin `app()` wrapper. +- Every new/changed behavior gets a test; no live Taiga server or network access in any test (spec §4). +- **Breaking change**: bare `taiga-mcp-server` (no subcommand) no longer starts the server. With Typer's `no_args_is_help=True` (same setting `ring-mcp-server`'s own CLI uses), it now prints the command list/help and exits 0 instead — this is a precision correction to the spec's "becomes a usage error" wording (see Task 6, which also amends the spec file itself for accuracy) — but it stops silently defaulting to `serve`, which is the compatibility break that matters. +- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on `feature/issue-267-add-mcp`. Do not rebase onto `master` as part of this plan — that happens later, once issue-267 merges (spec §Open items). + +--- + +## File Structure + +| File | Change | +|---|---| +| `taiga/mcp_server/cli.py` | Rewritten: argparse → Typer, 3 subcommands | +| `tests/test_mcp_server_cli.py` | Rewritten: `cli.main(argv)` calls → `CliRunner.invoke(cli.app, argv)` | +| `setup.cfg` | `[options.extras_require].mcp` gains `typer>=0.12.0` | +| `docs/mcp.rst` | Bare-invocation examples get ` serve`; new "Listing and calling tools directly" section | +| `AGENTS.md` | Two `claude mcp add ... -- taiga-mcp-server` examples get ` serve` | +| `changes/14039.feature` | New towncrier fragment | +| `changes/14039.removal` | New towncrier fragment (the breaking change) | +| `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` | One-sentence precision amendment (Task 6) | + +`_env_bool()` in `cli.py` is unchanged and reused as-is by the new `serve`/`list-tools`/`call` credential resolution — it has no Typer dependency, it's a pure env-var helper. + +--- + +## Task 1: Add the Typer dependency + +**Files:** +- Modify: `setup.cfg` + +**Interfaces:** +- Produces: `typer` importable wherever the `[mcp]` extra is installed — every later task in this plan depends on this. + +- [ ] **Step 1: Add the dependency** + +In `setup.cfg`, under `[options.extras_require]`: + +```ini +[options.extras_require] +docs = + sphinx + sphinx-rtd-theme +mcp = + mcp~=2.0 + typer>=0.12.0 +``` + +- [ ] **Step 2: Install it into the dev environment** + +Run: `pip install -e ".[mcp]"`, or `tox -e py313 --recreate` to rebuild the existing `.tox/py313` env (which already has `mcp` installed per the design's own investigation) so it picks up the new `typer` dependency from `setup.cfg`. + +- [ ] **Step 3: Verify the import works** + +Run: `python -c "import typer; print(typer.__version__)"` (or the equivalent inside the relevant tox env) — expect a version string, no `ImportError`. + +- [ ] **Step 4: Commit** + +```bash +git add setup.cfg +git commit -m "build(mcp): add typer dependency for the taiga-mcp-server CLI" +``` + +--- + +## Task 2: Rewrite `cli.py`'s skeleton and `serve` subcommand + +**Files:** +- Modify: `taiga/mcp_server/cli.py` (full rewrite) +- Test: `tests/test_mcp_server_cli.py` (rewrite the `main`-based tests; `_env_bool` tests are unchanged) + +**Interfaces:** +- Consumes: `taiga.mcp_server.auth.{DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure}` (all unchanged, from Task 1's untouched `auth.py`). +- Produces: `taiga.mcp_server.cli.app` (a `typer.Typer` instance — later tasks add commands to it), `taiga.mcp_server.cli.main() -> None` (console-script entry point), `taiga.mcp_server.cli._env_bool(name: str, default: bool) -> bool` (unchanged signature), `taiga.mcp_server.cli._resolve_credentials(host, token, token_type, username, password, tls_verify) -> Credentials` (new — later tasks reuse this for `list-tools` and `call`). + +- [ ] **Step 1: Write the failing tests for `serve`** + +Replace the `# --- main` section of `tests/test_mcp_server_cli.py` (keep the `_env_bool` tests above it untouched) with: + +```python +from typer.testing import CliRunner + +from taiga.mcp_server import cli + +runner = CliRunner() + +# --- serve ------------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_configures_from_token_argv(mock_configure, mock_mcp): + result = runner.invoke( + cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"] + ) + + assert result.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_serve_configures_from_username_password_argv(mock_configure, mock_mcp): + runner.invoke(cli.app, ["serve", "--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_serve_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): + result = runner.invoke(cli.app, ["serve"]) + + assert result.exit_code == 0 + 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_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): + with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): + runner.invoke(cli.app, ["serve", "--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_serve_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) + runner.invoke(cli.app, ["serve", "--token", "tok"]) + + assert mock_configure.call_args.args[0].tls_verify is True + + +# --- bare invocation (breaking change) --------------------------------------------------- + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): + result = runner.invoke(cli.app, []) + + assert "serve" in result.output + mock_configure.assert_not_called() + mock_mcp.run.assert_not_called() +``` + +Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: `ImportError`/`AttributeError` — `cli.app` doesn't exist yet (old `cli.py` is still argparse-based). + +- [ ] **Step 3: Rewrite `cli.py`** + +```python +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +import os +from typing import Optional + +import typer + +from .. import __version__ +from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure + +app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") + + +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 _resolve_credentials( + host: Optional[str], + token: Optional[str], + token_type: Optional[str], + username: Optional[str], + password: Optional[str], + tls_verify: Optional[bool], +) -> Credentials: + return Credentials( + host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), + tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, + token=token or os.environ.get("TAIGA_TOKEN"), + token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), + username=username or os.environ.get("TAIGA_USERNAME"), + password=password or os.environ.get("TAIGA_PASSWORD"), + ) + + +HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") +TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") +TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") +UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") +PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") +TlsVerifyOption = typer.Option( + None, + "--tls-verify/--no-tls-verify", + help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", +) + + +@app.command() +def serve( + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, +) -> None: + """Run the MCP server over stdio. + + Credentials can be passed as flags 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. + """ + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + mcp.run(transport="stdio") + + +def main() -> None: + """Entry point for the ``taiga-mcp-server`` console script.""" + app() + + +if __name__ == "__main__": + main() +``` + +Note: `--version` (previously `argparse`'s `action="version"`) is intentionally dropped from this step — Typer's idiom is a callback-based `--version` on the app itself, added in Task 3 alongside `list-tools` so it doesn't block this task's `serve`-only scope. If `--version` is needed sooner, it can be added here instead — not a hard dependency either way. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: PASS. (If `test_bare_invocation_no_longer_serves`'s exact exit code differs from what's asserted — the test above deliberately avoids asserting a specific exit code, only that `serve` wasn't triggered — no further action needed; if `"serve" in result.output` fails because Typer's help text formatting differs, inspect `result.output` and adjust the substring check, not the underlying behavior.) + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server + +BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer +starts the MCP server. Existing MCP client configs invoking the binary +with no arguments must add ' serve'." +``` + +--- + +## Task 3: Add `list-tools` subcommand + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `taiga.mcp_server.cli.{app, HostOption, TokenOption, TokenTypeOption, UsernameOption, PasswordOption, TlsVerifyOption, _resolve_credentials}` from Task 2; `taiga.mcp_server.server.mcp.list_tools() -> list[mcp_types.Tool]` (async, verified during design — see spec §2). +- Produces: `taiga-mcp-server list-tools [--verbose/-v]` subcommand. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- list-tools --------------------------------------------------------------------------- + + +def test_list_tools_lists_all_tool_names(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert "whoami" in result.output + assert "list_user_stories" in result.output + assert "create_issue" in result.output + + +def test_list_tools_default_excludes_schema(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert '"properties"' not in result.output + + +def test_list_tools_verbose_includes_schema(): + result = runner.invoke(cli.app, ["list-tools", "--verbose"]) + + assert result.exit_code == 0 + assert '"properties"' in result.output +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` +Expected: FAIL — no `list-tools` command registered on `cli.app` yet (Typer/Click reports "No such command"). + +- [ ] **Step 3: Add the command** + +In `taiga/mcp_server/cli.py`, add near the top: + +```python +import asyncio +import json +``` + +(alongside the existing `import os`), and add the command itself after `serve`: + +```python +@app.command("list-tools") +def list_tools( + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), +) -> None: + """List every tool exposed by the MCP server.""" + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + tools = asyncio.run(mcp.list_tools()) + for tool in sorted(tools, key=lambda t: t.name): + dumped = tool.model_dump(by_alias=True, exclude_none=True) + typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + typer.echo(json.dumps(dumped["inputSchema"], indent=2)) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` +Expected: PASS. + +- [ ] **Step 5: Run the full test file to check for regressions** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: all PASS (Task 2's `serve` tests unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add 'list-tools' subcommand to taiga-mcp-server" +``` + +--- + +## Task 4: Add `call` subcommand — success path + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `taiga.mcp_server.server.mcp.call_tool(name, arguments, context=None) -> CallToolResult` (async; `.structured_content` / `.content` fields — verified during design, spec §2–3). +- Produces: `taiga-mcp-server call --json/-j ''` (happy path only — Task 5 adds the error matrix). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- call: success path -------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_success_prints_structured_json_result(monkeypatch): + import taiga.mcp_server.server as server_mod + + monkeypatch.setattr(server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})()) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": 1, "username": "demo"} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` +Expected: FAIL — no `call` command registered yet. + +- [ ] **Step 3: Add the command** + +```python +@app.command() +def call( + tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), + arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, +) -> None: + """Call a single tool directly, bypassing an MCP client.""" + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid JSON in --json: {exc}", err=True) + raise typer.Exit(1) from exc + + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) +``` + +(No error handling yet — that's Task 5. This step only makes the success-path test pass.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add 'call' subcommand to taiga-mcp-server (success path)" +``` + +--- + +## Task 5: `call` subcommand — error matrix + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `mcp.server.mcpserver.exceptions.ToolError` (raised by `mcp.call_tool()` for unknown tool / validation failure / tool-internal exception, with `.__cause__` set to the underlying exception — verified live during design, spec §3); `mcp.shared.exceptions.MCPError` (unwrapped by the SDK, caught here defensively). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- call: error matrix --------------------------------------------------------------------- + + +def test_call_invalid_json_errors(): + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) + + assert result.exit_code == 1 + assert "Invalid JSON in --json" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_unknown_tool_errors(): + result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Unknown tool: this_tool_does_not_exist" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_missing_required_argument_errors(): + result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Invalid arguments for get_project" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_tool_internal_exception_errors(monkeypatch): + for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): + monkeypatch.delenv(var, raising=False) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Error calling whoami" in result.output + assert "credentials" in result.output +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -k "call_invalid_json or call_unknown_tool or call_missing_required or call_tool_internal" -v` +Expected: FAIL — `ToolError` currently propagates unhandled out of `call()`, causing `CliRunner` to report a non-zero exit but without the expected stderr message (Click captures the exception; `result.output` won't contain the intended text). + +- [ ] **Step 3: Add error handling** + +Add the import at the top of `cli.py`: + +```python +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from pydantic_core import ValidationError as PydanticValidationError +``` + +Wrap the `call_tool` invocation in `call()`: + +```python + try: + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + except ToolError as exc: + cause = exc.__cause__ + message = str(exc) + if message.startswith("Unknown tool: "): + typer.echo(message, err=True) + elif isinstance(cause, PydanticValidationError): + typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) + else: + typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) + raise typer.Exit(1) from exc + except MCPError as exc: + typer.echo(f"Error calling {tool_name}: {exc}", err=True) + raise typer.Exit(1) from exc + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) +``` + +(This replaces the bare `result = asyncio.run(...)` line from Task 4 with the `try/except` version; the two lines after it are unchanged.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: all PASS, including Task 4's success-path test and every earlier task's tests (full regression check). + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand" +``` + +--- + +## Task 6: Docs, changelog, and spec precision amendment + +**Files:** +- Modify: `docs/mcp.rst` +- Modify: `AGENTS.md` +- Create: `changes/14039.feature` +- Create: `changes/14039.removal` +- Modify: `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` + +**Interfaces:** none (documentation-only task). + +- [ ] **Step 1: Update `docs/mcp.rst`'s "Running the server standalone" example** + +At `docs/mcp.rst:93-98`, change: + +```rst +.. code:: shell + + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server +``` + +to: + +```rst +.. code:: shell + + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server serve +``` + +- [ ] **Step 2: Update the "Connecting an MCP client" example** + +At `docs/mcp.rst:113-119`, change the last line of the `claude mcp add` block from: + +```rst + -- taiga-mcp-server +``` + +to: + +```rst + -- taiga-mcp-server serve +``` + +- [ ] **Step 3: Add a new "Listing and calling tools directly" section** + +Insert, right after the "Running the server standalone" section (after line 102, before the "Connecting an MCP client" heading at line 104): + +```rst +********************************** +Listing and calling tools directly +********************************** + +Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set +directly from a shell: + +.. code:: shell + + # list every tool, one per line + taiga-mcp-server list-tools + + # ...with each tool's JSON input schema + taiga-mcp-server list-tools --verbose + + # call a single tool by name, passing its arguments as a JSON object + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server call whoami --json '{}' + + taiga-mcp-server call get_project --json '{"project": "myproject"}' + +On success, ``call`` prints the tool's JSON result to stdout. On failure +(unknown tool name, invalid arguments, or an error from the underlying +Taiga API call) it prints a message to stderr and exits with a non-zero +status. +``` + +- [ ] **Step 4: Update `AGENTS.md`** + +At `AGENTS.md`, in the two `claude mcp add` examples in step 4 (lines ~81-101), append ` serve` to the command in both: + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_USERNAME= \ + -e TAIGA_PASSWORD= \ + -- /absolute/path/to/taiga-mcp-server serve +``` + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_TOKEN= \ + -- /absolute/path/to/taiga-mcp-server serve +``` + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_TOKEN= \ + -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve +``` + +- [ ] **Step 5: Add towncrier changelog fragments** + +Create `changes/14039.feature`: + +``` +Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. +``` + +Create `changes/14039.removal`: + +``` +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. +``` + +- [ ] **Step 6: Amend the spec's bare-invocation wording for accuracy** + +In `artifacts/specs/2026-08-31-mcp-cli-parity-design.md`, in the "Breaking change" section, replace: + +``` +**This design makes `serve` an explicit, required subcommand** — +bare invocation becomes a Typer usage error. This was a deliberate choice +(matching ring's shape exactly) made during design, not a byproduct. +``` + +with: + +``` +**This design makes `serve` an explicit, required subcommand** — bare +invocation no longer starts the server. With Typer's `no_args_is_help=True` +(the same setting ring-mcp-server's own CLI uses), it shows the command +list/help and exits 0, rather than becoming a hard usage error — the +compatibility break is that it no longer silently defaults to `serve`, not +the exact exit code. This was a deliberate choice (matching ring's shape +exactly) made during design, not a byproduct. +``` + +- [ ] **Step 7: Commit** + +```bash +git add docs/mcp.rst AGENTS.md changes/14039.feature changes/14039.removal artifacts/specs/2026-08-31-mcp-cli-parity-design.md +git commit -m "docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands" +``` + +--- + +## Task 7: Final full-suite regression check + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full test suite** + +Run: `tox -e py313` +Expected: all tests PASS, including every test from Tasks 2–5 and the pre-existing suite (`test_mcp_server.py`, `test_mcp_server_auth.py`, and the rest of the repo's tests untouched by this plan). + +- [ ] **Step 2: Run linting** + +Run: `tox -e ruff,black,isort` (the three lint/format-check envs defined in `tox.ini`) against the full repo. +Expected: no violations on `taiga/mcp_server/cli.py` or `tests/test_mcp_server_cli.py`. If `black`/`isort` report formatting diffs, run `tox -e blacken,isort_format` to auto-fix, then re-run the check envs. + +- [ ] **Step 3: Confirm no unintended changes to untouched files** + +Run: `git diff --stat feature/issue-267-add-mcp..HEAD` +Expected: only the files listed in this plan's "File Structure" table appear. From a747628fa978e59a0c546c0dd63cd3d2cca7c7f7 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:35:12 +0200 Subject: [PATCH 15/32] =?UTF-8?q?docs(mcp):=20fix=20plan=20preflight=20gap?= =?UTF-8?q?=20=E2=80=94=20implement=20--version=20in=20Task=202,=20not=20d?= =?UTF-8?q?eferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artifacts/plans/2026-08-31-mcp-cli-parity.md | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md index 82e33df..9185e6e 100644 --- a/artifacts/plans/2026-08-31-mcp-cli-parity.md +++ b/artifacts/plans/2026-08-31-mcp-cli-parity.md @@ -178,6 +178,18 @@ def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): assert "serve" in result.output mock_configure.assert_not_called() mock_mcp.run.assert_not_called() + + +# --- --version -------------------------------------------------------------------------- + + +def test_version_flag_prints_version_and_exits(): + from taiga import __version__ + + result = runner.invoke(cli.app, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.output ``` Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). @@ -207,6 +219,21 @@ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"taiga-mcp-server (python-taiga {__version__})") + raise typer.Exit() + + +@app.callback() +def _main( + version: Optional[bool] = typer.Option( + None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." + ), +) -> None: + """Taiga MCP server & CLI.""" + + def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) if value is None: @@ -276,7 +303,7 @@ if __name__ == "__main__": main() ``` -Note: `--version` (previously `argparse`'s `action="version"`) is intentionally dropped from this step — Typer's idiom is a callback-based `--version` on the app itself, added in Task 3 alongside `list-tools` so it doesn't block this task's `serve`-only scope. If `--version` is needed sooner, it can be added here instead — not a hard dependency either way. +This preserves the previous argparse CLI's `--version` flag (`action="version"`) via Typer's standard eager-callback idiom (`_main`'s `@app.callback()`), applying to the whole `app`, not just `serve`. - [ ] **Step 4: Run the tests to verify they pass** From 7817576535f2cbca183ce8981cea0687c024f6eb Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:36:20 +0200 Subject: [PATCH 16/32] build(mcp): add typer dependency for the taiga-mcp-server CLI --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 063b3af..11af04c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ docs = sphinx-rtd-theme mcp = mcp~=2.0 + typer>=0.12.0 [sdist] formats = zip From 33f531aea146271b7b111ff42457e95a2f39120d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:40:49 +0200 Subject: [PATCH 17/32] feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer starts the MCP server. Existing MCP client configs invoking the binary with no arguments must add ' serve'. Co-Authored-By: Claude Sonnet 5 --- taiga/mcp_server/cli.py | 119 +++++++++++++++++++++-------------- tests/test_mcp_server_cli.py | 54 ++++++++++++---- 2 files changed, 113 insertions(+), 60 deletions(-) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 3cff5c5..6b65b08 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -4,13 +4,30 @@ from __future__ import annotations -import argparse import os -import sys + +import typer from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure +app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"taiga-mcp-server (python-taiga {__version__})") + raise typer.Exit() + + +@app.callback() +def _main( + version: bool | None = typer.Option( + None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." + ), +) -> None: + """Taiga MCP server & CLI.""" + def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) @@ -19,57 +36,63 @@ def _env_bool(name: str, default: bool) -> bool: 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, - ) +def _resolve_credentials( + host: str | None, + token: str | None, + token_type: str | None, + username: str | None, + password: str | None, + tls_verify: bool | None, +) -> Credentials: + return Credentials( + host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), + tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, + token=token or os.environ.get("TAIGA_TOKEN"), + token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), + username=username or os.environ.get("TAIGA_USERNAME"), + password=password or os.environ.get("TAIGA_PASSWORD"), ) + +HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") +TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") +TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") +UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") +PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") +TlsVerifyOption = typer.Option( + None, + "--tls-verify/--no-tls-verify", + help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", +) + + +@app.command() +def serve( + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, +) -> None: + """Run the MCP server over stdio. + + Credentials can be passed as flags 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. + """ + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + from .server import mcp mcp.run(transport="stdio") - return 0 + + +def main() -> None: + """Entry point for the ``taiga-mcp-server`` console script.""" + app() if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 33a3d46..482760d 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -3,8 +3,12 @@ import os from unittest.mock import patch +from typer.testing import CliRunner + from taiga.mcp_server import cli +runner = CliRunner() + # --- _env_bool ------------------------------------------------------------------------------ @@ -27,15 +31,15 @@ def test_env_bool_truthy_values(): assert cli._env_bool("TAIGA_TLS_VERIFY", False) is True -# --- main ----------------------------------------------------------------------------------- +# --- serve ------------------------------------------------------------------------------ @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"]) +def test_serve_configures_from_token_argv(mock_configure, mock_mcp): + result = runner.invoke(cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"]) - assert exit_code == 0 + assert result.exit_code == 0 mock_configure.assert_called_once() credentials = mock_configure.call_args.args[0] assert credentials.host == "https://example.com" @@ -46,8 +50,8 @@ def test_main_configures_from_token_argv(mock_configure, mock_mcp): @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"]) +def test_serve_configures_from_username_password_argv(mock_configure, mock_mcp): + runner.invoke(cli.app, ["serve", "--username", "alice", "--password", "secret", "--tls-verify"]) credentials = mock_configure.call_args.args[0] assert credentials.username == "alice" @@ -58,15 +62,16 @@ def test_main_configures_from_username_password_argv(mock_configure, mock_mcp): @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_reads_credentials_from_env(mock_configure, mock_mcp): +def test_serve_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([]) + result = runner.invoke(cli.app, ["serve"]) + assert result.exit_code == 0 credentials = mock_configure.call_args.args[0] assert credentials.host == "https://env.example.com" assert credentials.token == "env-tok" @@ -75,18 +80,43 @@ def test_main_reads_credentials_from_env(mock_configure, mock_mcp): @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): +def test_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): - cli.main(["--token", "tok"]) + runner.invoke(cli.app, ["serve", "--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): +def test_serve_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"]) + runner.invoke(cli.app, ["serve", "--token", "tok"]) assert mock_configure.call_args.args[0].tls_verify is True + + +# --- bare invocation (breaking change) --------------------------------------------------- + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): + result = runner.invoke(cli.app, []) + + assert "serve" in result.output + mock_configure.assert_not_called() + mock_mcp.run.assert_not_called() + + +# --- --version -------------------------------------------------------------------------- + + +def test_version_flag_prints_version_and_exits(): + from taiga import __version__ + + result = runner.invoke(cli.app, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.output From 841c94a40ba4eb26d74bc031242e472032047fc2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:46:34 +0200 Subject: [PATCH 18/32] feat(mcp): add 'list-tools' subcommand to taiga-mcp-server --- taiga/mcp_server/cli.py | 25 +++++++++++++++++++++++++ tests/test_mcp_server_cli.py | 26 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 6b65b08..621a498 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -4,6 +4,8 @@ from __future__ import annotations +import asyncio +import json import os import typer @@ -89,6 +91,29 @@ def serve( mcp.run(transport="stdio") +@app.command("list-tools") +def list_tools( + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), +) -> None: + """List every tool exposed by the MCP server.""" + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + tools = asyncio.run(mcp.list_tools()) + for tool in sorted(tools, key=lambda t: t.name): + dumped = tool.model_dump(by_alias=True, exclude_none=True) + typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + typer.echo(json.dumps(dumped["inputSchema"], indent=2)) + + def main() -> None: """Entry point for the ``taiga-mcp-server`` console script.""" app() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 482760d..b31eb83 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -97,6 +97,32 @@ def test_serve_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock assert mock_configure.call_args.args[0].tls_verify is True +# --- list-tools --------------------------------------------------------------------------- + + +def test_list_tools_lists_all_tool_names(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert "whoami" in result.output + assert "list_user_stories" in result.output + assert "create_issue" in result.output + + +def test_list_tools_default_excludes_schema(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert '"properties"' not in result.output + + +def test_list_tools_verbose_includes_schema(): + result = runner.invoke(cli.app, ["list-tools", "--verbose"]) + + assert result.exit_code == 0 + assert '"properties"' in result.output + + # --- bare invocation (breaking change) --------------------------------------------------- From 5727e2347fcf38df25cfd34365c232b9aa75895a Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:51:41 +0200 Subject: [PATCH 19/32] feat(mcp): add 'call' subcommand to taiga-mcp-server (success path) --- taiga/mcp_server/cli.py | 28 ++++++++++++++++++++++++++++ tests/test_mcp_server_cli.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 621a498..3edc1d6 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -114,6 +114,34 @@ def list_tools( typer.echo(json.dumps(dumped["inputSchema"], indent=2)) +@app.command() +def call( + tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), + arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, +) -> None: + """Call a single tool directly, bypassing an MCP client.""" + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid JSON in --json: {exc}", err=True) + raise typer.Exit(1) from exc + + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) + + def main() -> None: """Entry point for the ``taiga-mcp-server`` console script.""" app() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index b31eb83..f17a1bb 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from unittest.mock import patch @@ -123,6 +124,24 @@ def test_list_tools_verbose_includes_schema(): assert '"properties"' in result.output +# --- call: success path -------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_success_prints_structured_json_result(monkeypatch): + import taiga.mcp_server.server as server_mod + + monkeypatch.setattr( + server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})() + ) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": 1, "username": "demo"} + + # --- bare invocation (breaking change) --------------------------------------------------- From 2573c7d41ee86c47d90334b240da9e24e9d37352 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:56:28 +0200 Subject: [PATCH 20/32] feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand --- taiga/mcp_server/cli.py | 19 ++++++++++++++++- tests/test_mcp_server_cli.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 3edc1d6..75aab3d 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -9,6 +9,9 @@ import os import typer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from pydantic_core import ValidationError as PydanticValidationError from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure @@ -136,7 +139,21 @@ def call( from .server import mcp - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + try: + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + except ToolError as exc: + cause = exc.__cause__ + message = str(exc) + if message.startswith("Unknown tool: "): + typer.echo(message, err=True) + elif isinstance(cause, PydanticValidationError): + typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) + else: + typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) + raise typer.Exit(1) from exc + except MCPError as exc: + typer.echo(f"Error calling {tool_name}: {exc}", err=True) + raise typer.Exit(1) from exc payload = result.structured_content if result.structured_content is not None else result.content typer.echo(json.dumps(payload, indent=2, default=str)) diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index f17a1bb..2174d66 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -142,6 +142,47 @@ def test_call_success_prints_structured_json_result(monkeypatch): assert json.loads(result.output) == {"id": 1, "username": "demo"} +# --- call: error matrix --------------------------------------------------------------------- + + +def test_call_invalid_json_errors(): + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) + + assert result.exit_code == 1 + assert "Invalid JSON in --json" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_unknown_tool_errors(): + result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Unknown tool: this_tool_does_not_exist" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_missing_required_argument_errors(): + result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Invalid arguments for get_project" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_tool_internal_exception_errors(monkeypatch): + for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): + monkeypatch.delenv(var, raising=False) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Error calling whoami" in result.output + assert "credentials" in result.output + + # --- bare invocation (breaking change) --------------------------------------------------- From 9d2ef6eedb163d8add9d871f9ddf4c492f842112 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 14:00:30 +0200 Subject: [PATCH 21/32] docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands --- AGENTS.md | 6 ++-- .../specs/2026-08-31-mcp-cli-parity-design.md | 10 ++++-- changes/14039.feature | 1 + changes/14039.removal | 1 + docs/mcp.rst | 32 +++++++++++++++++-- 5 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 changes/14039.feature create mode 100644 changes/14039.removal diff --git a/AGENTS.md b/AGENTS.md index 53d6f38..fa58ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,14 +82,14 @@ globally" / "add it to my user-wide config", follow this procedure: -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_USERNAME= \ -e TAIGA_PASSWORD= \ - -- /absolute/path/to/taiga-mcp-server + -- /absolute/path/to/taiga-mcp-server serve ``` or, with a token instead of username/password: ```bash claude mcp add --scope user taiga \ -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ - -- /absolute/path/to/taiga-mcp-server + -- /absolute/path/to/taiga-mcp-server serve ``` With `uvx` there's no path to resolve — pass the `uvx` invocation itself as the command: @@ -97,7 +97,7 @@ globally" / "add it to my user-wide config", follow this procedure: claude mcp add --scope user taiga \ -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ - -- uvx --from "python-taiga[mcp]" taiga-mcp-server + -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve ``` `--scope user` (not `local`/`project`) is what makes it "user-wide" — available in every project for that user, stored outside this repo. diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md index 60a962d..af3a047 100644 --- a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md +++ b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md @@ -59,9 +59,13 @@ function; no dynamic generation is introduced). ## Breaking change (must be called out prominently) Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio -server. **This design makes `serve` an explicit, required subcommand** — -bare invocation becomes a Typer usage error. This was a deliberate choice -(matching ring's shape exactly) made during design, not a byproduct. +server. **This design makes `serve` an explicit, required subcommand** — bare +invocation no longer starts the server. With Typer's `no_args_is_help=True` +(the same setting ring-mcp-server's own CLI uses), it shows the command +list/help and exits 0, rather than becoming a hard usage error — the +compatibility break is that it no longer silently defaults to `serve`, not +the exact exit code. This was a deliberate choice (matching ring's shape +exactly) made during design, not a byproduct. Impact: every existing MCP client config that invokes the binary with no arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` diff --git a/changes/14039.feature b/changes/14039.feature new file mode 100644 index 0000000..22cf153 --- /dev/null +++ b/changes/14039.feature @@ -0,0 +1 @@ +Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. diff --git a/changes/14039.removal b/changes/14039.removal new file mode 100644 index 0000000..3c2a823 --- /dev/null +++ b/changes/14039.removal @@ -0,0 +1 @@ +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. diff --git a/docs/mcp.rst b/docs/mcp.rst index dd2b926..8277d31 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -95,12 +95,40 @@ Running the server standalone TAIGA_HOST=https://taiga.example.com \ TAIGA_USERNAME=myuser \ TAIGA_PASSWORD=mypassword \ - taiga-mcp-server + taiga-mcp-server serve 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. +********************************** +Listing and calling tools directly +********************************** + +Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set +directly from a shell: + +.. code:: shell + + # list every tool, one per line + taiga-mcp-server list-tools + + # ...with each tool's JSON input schema + taiga-mcp-server list-tools --verbose + + # call a single tool by name, passing its arguments as a JSON object + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server call whoami --json '{}' + + taiga-mcp-server call get_project --json '{"project": "myproject"}' + +On success, ``call`` prints the tool's JSON result to stdout. On failure +(unknown tool name, invalid arguments, or an error from the underlying +Taiga API call) it prints a message to stderr and exits with a non-zero +status. + ***************************** Connecting an MCP client ***************************** @@ -116,7 +144,7 @@ available in every project: -e TAIGA_HOST=https://taiga.example.com \ -e TAIGA_USERNAME=myuser \ -e TAIGA_PASSWORD=mypassword \ - -- taiga-mcp-server + -- taiga-mcp-server serve ``--scope user`` stores the registration in your own Claude configuration, not in any particular project. Check it went through with: From 1cc24a59660ab595c531735ca20bded49a754492 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 14:14:11 +0200 Subject: [PATCH 22/32] fix(mcp): address final review findings - Correct spec/plan wording: bare taiga-mcp-server invocation exits 2 (Click's usage-error path for Typer's no_args_is_help), not 0 as previously (incorrectly) documented; verified against the installed click/typer in .tox/py313. - Pin the bare-invocation exit code in test_bare_invocation_no_longer_serves (exit_code != 0). - Fix docs/mcp.rst uvx install example so it no longer implies running the bare (now-erroring) command; use --help instead. - Import ValidationError from the public pydantic package instead of the internal pydantic_core (same class, stable import path). - Surface the --token/--password process-list warning on the root --help and call --help, not just serve --help. - Document the taiga.mcp_server.cli.main() signature change (argv: list[str] | None = None) -> int to () -> None in the 14039.removal changelog fragment. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GdNTA2ZXdKMYs1MBCF4uB4 --- .gitignore | 1 + artifacts/activity-log.md | 42 - .../evaluations/2026-08-24-mcp-sdk-rewrite.md | 26 - artifacts/plans/2026-08-31-mcp-cli-parity.md | 789 ------------------ .../specs/2026-08-31-mcp-cli-parity-design.md | 267 ------ changes/14039.removal | 2 +- docs/mcp.rst | 2 +- taiga/mcp_server/cli.py | 15 +- tests/test_mcp_server_cli.py | 1 + 9 files changed, 16 insertions(+), 1129 deletions(-) delete mode 100644 artifacts/activity-log.md delete mode 100644 artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md delete mode 100644 artifacts/plans/2026-08-31-mcp-cli-parity.md delete mode 100644 artifacts/specs/2026-08-31-mcp-cli-parity-design.md diff --git a/.gitignore b/.gitignore index 3dff66b..3008ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ debian/python3-taiga* .venv *.egg-link .superpowers +artifacts diff --git a/artifacts/activity-log.md b/artifacts/activity-log.md deleted file mode 100644 index 16d7f28..0000000 --- a/artifacts/activity-log.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 8d5834c..0000000 --- a/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md +++ /dev/null @@ -1,26 +0,0 @@ -# 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. diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md deleted file mode 100644 index 9185e6e..0000000 --- a/artifacts/plans/2026-08-31-mcp-cli-parity.md +++ /dev/null @@ -1,789 +0,0 @@ -# Taiga MCP CLI Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give `taiga-mcp-server` the same CLI verb shape as `ring-mcp-server` — `serve`, `list-tools [--verbose]`, `call --json ''` — without touching any of the ~45 hand-implemented `@mcp.tool()` functions in `taiga/mcp_server/server.py`. - -**Architecture:** Rewrite `taiga/mcp_server/cli.py` from `argparse` to `typer`. `serve` preserves today's behavior byte-for-byte, now behind an explicit subcommand instead of the bare invocation. `list-tools` and `call` invoke the already-constructed `mcp` object in-process via `asyncio.run(mcp.list_tools())` / `asyncio.run(mcp.call_tool(name, arguments))` — no subprocess, no live MCP client round trip. - -**Tech Stack:** Python 3.11–3.14, `typer` (new dependency, `>=0.12.0` to match `ring-mcp-server`'s own floor), `mcp==2.0.0` (already pinned via the `[mcp]` extra), `pytest` + `typer.testing.CliRunner`. - -**Spec:** `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` - -## Global Constraints - -- Do not modify `taiga/mcp_server/server.py`, `taiga/mcp_server/auth.py`, or `taiga/mcp_server/serialize.py` — tool bodies, credential resolution, and serialization stay exactly as they are (spec §Non-goals). -- Do not rename any of the ~45 existing tool functions or their parameters (spec §Non-goals). -- `serve`'s auth flags/env-var precedence (flag > env > default) must remain identical to today's argparse behavior (spec §1). -- Console script stays `taiga-mcp-server = taiga.mcp_server.cli:main` in `setup.cfg` — no entry-point path change, `main()` just becomes a thin `app()` wrapper. -- Every new/changed behavior gets a test; no live Taiga server or network access in any test (spec §4). -- **Breaking change**: bare `taiga-mcp-server` (no subcommand) no longer starts the server. With Typer's `no_args_is_help=True` (same setting `ring-mcp-server`'s own CLI uses), it now prints the command list/help and exits 0 instead — this is a precision correction to the spec's "becomes a usage error" wording (see Task 6, which also amends the spec file itself for accuracy) — but it stops silently defaulting to `serve`, which is the compatibility break that matters. -- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on `feature/issue-267-add-mcp`. Do not rebase onto `master` as part of this plan — that happens later, once issue-267 merges (spec §Open items). - ---- - -## File Structure - -| File | Change | -|---|---| -| `taiga/mcp_server/cli.py` | Rewritten: argparse → Typer, 3 subcommands | -| `tests/test_mcp_server_cli.py` | Rewritten: `cli.main(argv)` calls → `CliRunner.invoke(cli.app, argv)` | -| `setup.cfg` | `[options.extras_require].mcp` gains `typer>=0.12.0` | -| `docs/mcp.rst` | Bare-invocation examples get ` serve`; new "Listing and calling tools directly" section | -| `AGENTS.md` | Two `claude mcp add ... -- taiga-mcp-server` examples get ` serve` | -| `changes/14039.feature` | New towncrier fragment | -| `changes/14039.removal` | New towncrier fragment (the breaking change) | -| `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` | One-sentence precision amendment (Task 6) | - -`_env_bool()` in `cli.py` is unchanged and reused as-is by the new `serve`/`list-tools`/`call` credential resolution — it has no Typer dependency, it's a pure env-var helper. - ---- - -## Task 1: Add the Typer dependency - -**Files:** -- Modify: `setup.cfg` - -**Interfaces:** -- Produces: `typer` importable wherever the `[mcp]` extra is installed — every later task in this plan depends on this. - -- [ ] **Step 1: Add the dependency** - -In `setup.cfg`, under `[options.extras_require]`: - -```ini -[options.extras_require] -docs = - sphinx - sphinx-rtd-theme -mcp = - mcp~=2.0 - typer>=0.12.0 -``` - -- [ ] **Step 2: Install it into the dev environment** - -Run: `pip install -e ".[mcp]"`, or `tox -e py313 --recreate` to rebuild the existing `.tox/py313` env (which already has `mcp` installed per the design's own investigation) so it picks up the new `typer` dependency from `setup.cfg`. - -- [ ] **Step 3: Verify the import works** - -Run: `python -c "import typer; print(typer.__version__)"` (or the equivalent inside the relevant tox env) — expect a version string, no `ImportError`. - -- [ ] **Step 4: Commit** - -```bash -git add setup.cfg -git commit -m "build(mcp): add typer dependency for the taiga-mcp-server CLI" -``` - ---- - -## Task 2: Rewrite `cli.py`'s skeleton and `serve` subcommand - -**Files:** -- Modify: `taiga/mcp_server/cli.py` (full rewrite) -- Test: `tests/test_mcp_server_cli.py` (rewrite the `main`-based tests; `_env_bool` tests are unchanged) - -**Interfaces:** -- Consumes: `taiga.mcp_server.auth.{DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure}` (all unchanged, from Task 1's untouched `auth.py`). -- Produces: `taiga.mcp_server.cli.app` (a `typer.Typer` instance — later tasks add commands to it), `taiga.mcp_server.cli.main() -> None` (console-script entry point), `taiga.mcp_server.cli._env_bool(name: str, default: bool) -> bool` (unchanged signature), `taiga.mcp_server.cli._resolve_credentials(host, token, token_type, username, password, tls_verify) -> Credentials` (new — later tasks reuse this for `list-tools` and `call`). - -- [ ] **Step 1: Write the failing tests for `serve`** - -Replace the `# --- main` section of `tests/test_mcp_server_cli.py` (keep the `_env_bool` tests above it untouched) with: - -```python -from typer.testing import CliRunner - -from taiga.mcp_server import cli - -runner = CliRunner() - -# --- serve ------------------------------------------------------------------------------ - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_configures_from_token_argv(mock_configure, mock_mcp): - result = runner.invoke( - cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"] - ) - - assert result.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_serve_configures_from_username_password_argv(mock_configure, mock_mcp): - runner.invoke(cli.app, ["serve", "--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_serve_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): - result = runner.invoke(cli.app, ["serve"]) - - assert result.exit_code == 0 - 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_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): - with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): - runner.invoke(cli.app, ["serve", "--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_serve_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) - runner.invoke(cli.app, ["serve", "--token", "tok"]) - - assert mock_configure.call_args.args[0].tls_verify is True - - -# --- bare invocation (breaking change) --------------------------------------------------- - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): - result = runner.invoke(cli.app, []) - - assert "serve" in result.output - mock_configure.assert_not_called() - mock_mcp.run.assert_not_called() - - -# --- --version -------------------------------------------------------------------------- - - -def test_version_flag_prints_version_and_exits(): - from taiga import __version__ - - result = runner.invoke(cli.app, ["--version"]) - - assert result.exit_code == 0 - assert __version__ in result.output -``` - -Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: `ImportError`/`AttributeError` — `cli.app` doesn't exist yet (old `cli.py` is still argparse-based). - -- [ ] **Step 3: Rewrite `cli.py`** - -```python -# python-taiga -# Copyright 2015 Nephila -# See LICENSE for details. - -from __future__ import annotations - -import os -from typing import Optional - -import typer - -from .. import __version__ -from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure - -app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(f"taiga-mcp-server (python-taiga {__version__})") - raise typer.Exit() - - -@app.callback() -def _main( - version: Optional[bool] = typer.Option( - None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." - ), -) -> None: - """Taiga MCP server & CLI.""" - - -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 _resolve_credentials( - host: Optional[str], - token: Optional[str], - token_type: Optional[str], - username: Optional[str], - password: Optional[str], - tls_verify: Optional[bool], -) -> Credentials: - return Credentials( - host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), - tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, - token=token or os.environ.get("TAIGA_TOKEN"), - token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), - username=username or os.environ.get("TAIGA_USERNAME"), - password=password or os.environ.get("TAIGA_PASSWORD"), - ) - - -HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") -TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") -TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") -UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") -PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") -TlsVerifyOption = typer.Option( - None, - "--tls-verify/--no-tls-verify", - help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", -) - - -@app.command() -def serve( - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, -) -> None: - """Run the MCP server over stdio. - - Credentials can be passed as flags 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. - """ - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - mcp.run(transport="stdio") - - -def main() -> None: - """Entry point for the ``taiga-mcp-server`` console script.""" - app() - - -if __name__ == "__main__": - main() -``` - -This preserves the previous argparse CLI's `--version` flag (`action="version"`) via Typer's standard eager-callback idiom (`_main`'s `@app.callback()`), applying to the whole `app`, not just `serve`. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: PASS. (If `test_bare_invocation_no_longer_serves`'s exact exit code differs from what's asserted — the test above deliberately avoids asserting a specific exit code, only that `serve` wasn't triggered — no further action needed; if `"serve" in result.output` fails because Typer's help text formatting differs, inspect `result.output` and adjust the substring check, not the underlying behavior.) - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server - -BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer -starts the MCP server. Existing MCP client configs invoking the binary -with no arguments must add ' serve'." -``` - ---- - -## Task 3: Add `list-tools` subcommand - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `taiga.mcp_server.cli.{app, HostOption, TokenOption, TokenTypeOption, UsernameOption, PasswordOption, TlsVerifyOption, _resolve_credentials}` from Task 2; `taiga.mcp_server.server.mcp.list_tools() -> list[mcp_types.Tool]` (async, verified during design — see spec §2). -- Produces: `taiga-mcp-server list-tools [--verbose/-v]` subcommand. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- list-tools --------------------------------------------------------------------------- - - -def test_list_tools_lists_all_tool_names(): - result = runner.invoke(cli.app, ["list-tools"]) - - assert result.exit_code == 0 - assert "whoami" in result.output - assert "list_user_stories" in result.output - assert "create_issue" in result.output - - -def test_list_tools_default_excludes_schema(): - result = runner.invoke(cli.app, ["list-tools"]) - - assert result.exit_code == 0 - assert '"properties"' not in result.output - - -def test_list_tools_verbose_includes_schema(): - result = runner.invoke(cli.app, ["list-tools", "--verbose"]) - - assert result.exit_code == 0 - assert '"properties"' in result.output -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` -Expected: FAIL — no `list-tools` command registered on `cli.app` yet (Typer/Click reports "No such command"). - -- [ ] **Step 3: Add the command** - -In `taiga/mcp_server/cli.py`, add near the top: - -```python -import asyncio -import json -``` - -(alongside the existing `import os`), and add the command itself after `serve`: - -```python -@app.command("list-tools") -def list_tools( - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, - verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), -) -> None: - """List every tool exposed by the MCP server.""" - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - tools = asyncio.run(mcp.list_tools()) - for tool in sorted(tools, key=lambda t: t.name): - dumped = tool.model_dump(by_alias=True, exclude_none=True) - typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") - if verbose: - typer.echo(json.dumps(dumped["inputSchema"], indent=2)) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` -Expected: PASS. - -- [ ] **Step 5: Run the full test file to check for regressions** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: all PASS (Task 2's `serve` tests unaffected). - -- [ ] **Step 6: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add 'list-tools' subcommand to taiga-mcp-server" -``` - ---- - -## Task 4: Add `call` subcommand — success path - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `taiga.mcp_server.server.mcp.call_tool(name, arguments, context=None) -> CallToolResult` (async; `.structured_content` / `.content` fields — verified during design, spec §2–3). -- Produces: `taiga-mcp-server call --json/-j ''` (happy path only — Task 5 adds the error matrix). - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- call: success path -------------------------------------------------------------------- - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_success_prints_structured_json_result(monkeypatch): - import taiga.mcp_server.server as server_mod - - monkeypatch.setattr(server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})()) - - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": 1, "username": "demo"} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` -Expected: FAIL — no `call` command registered yet. - -- [ ] **Step 3: Add the command** - -```python -@app.command() -def call( - tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), - arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, -) -> None: - """Call a single tool directly, bypassing an MCP client.""" - try: - parsed_arguments = json.loads(arguments) - except json.JSONDecodeError as exc: - typer.echo(f"Invalid JSON in --json: {exc}", err=True) - raise typer.Exit(1) from exc - - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) - - payload = result.structured_content if result.structured_content is not None else result.content - typer.echo(json.dumps(payload, indent=2, default=str)) -``` - -(No error handling yet — that's Task 5. This step only makes the success-path test pass.) - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add 'call' subcommand to taiga-mcp-server (success path)" -``` - ---- - -## Task 5: `call` subcommand — error matrix - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `mcp.server.mcpserver.exceptions.ToolError` (raised by `mcp.call_tool()` for unknown tool / validation failure / tool-internal exception, with `.__cause__` set to the underlying exception — verified live during design, spec §3); `mcp.shared.exceptions.MCPError` (unwrapped by the SDK, caught here defensively). - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- call: error matrix --------------------------------------------------------------------- - - -def test_call_invalid_json_errors(): - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) - - assert result.exit_code == 1 - assert "Invalid JSON in --json" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_unknown_tool_errors(): - result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Unknown tool: this_tool_does_not_exist" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_missing_required_argument_errors(): - result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Invalid arguments for get_project" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_tool_internal_exception_errors(monkeypatch): - for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): - monkeypatch.delenv(var, raising=False) - - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Error calling whoami" in result.output - assert "credentials" in result.output -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -k "call_invalid_json or call_unknown_tool or call_missing_required or call_tool_internal" -v` -Expected: FAIL — `ToolError` currently propagates unhandled out of `call()`, causing `CliRunner` to report a non-zero exit but without the expected stderr message (Click captures the exception; `result.output` won't contain the intended text). - -- [ ] **Step 3: Add error handling** - -Add the import at the top of `cli.py`: - -```python -from mcp.server.mcpserver.exceptions import ToolError -from mcp.shared.exceptions import MCPError -from pydantic_core import ValidationError as PydanticValidationError -``` - -Wrap the `call_tool` invocation in `call()`: - -```python - try: - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) - except ToolError as exc: - cause = exc.__cause__ - message = str(exc) - if message.startswith("Unknown tool: "): - typer.echo(message, err=True) - elif isinstance(cause, PydanticValidationError): - typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) - else: - typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) - raise typer.Exit(1) from exc - except MCPError as exc: - typer.echo(f"Error calling {tool_name}: {exc}", err=True) - raise typer.Exit(1) from exc - - payload = result.structured_content if result.structured_content is not None else result.content - typer.echo(json.dumps(payload, indent=2, default=str)) -``` - -(This replaces the bare `result = asyncio.run(...)` line from Task 4 with the `try/except` version; the two lines after it are unchanged.) - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: all PASS, including Task 4's success-path test and every earlier task's tests (full regression check). - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand" -``` - ---- - -## Task 6: Docs, changelog, and spec precision amendment - -**Files:** -- Modify: `docs/mcp.rst` -- Modify: `AGENTS.md` -- Create: `changes/14039.feature` -- Create: `changes/14039.removal` -- Modify: `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` - -**Interfaces:** none (documentation-only task). - -- [ ] **Step 1: Update `docs/mcp.rst`'s "Running the server standalone" example** - -At `docs/mcp.rst:93-98`, change: - -```rst -.. code:: shell - - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server -``` - -to: - -```rst -.. code:: shell - - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server serve -``` - -- [ ] **Step 2: Update the "Connecting an MCP client" example** - -At `docs/mcp.rst:113-119`, change the last line of the `claude mcp add` block from: - -```rst - -- taiga-mcp-server -``` - -to: - -```rst - -- taiga-mcp-server serve -``` - -- [ ] **Step 3: Add a new "Listing and calling tools directly" section** - -Insert, right after the "Running the server standalone" section (after line 102, before the "Connecting an MCP client" heading at line 104): - -```rst -********************************** -Listing and calling tools directly -********************************** - -Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set -directly from a shell: - -.. code:: shell - - # list every tool, one per line - taiga-mcp-server list-tools - - # ...with each tool's JSON input schema - taiga-mcp-server list-tools --verbose - - # call a single tool by name, passing its arguments as a JSON object - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server call whoami --json '{}' - - taiga-mcp-server call get_project --json '{"project": "myproject"}' - -On success, ``call`` prints the tool's JSON result to stdout. On failure -(unknown tool name, invalid arguments, or an error from the underlying -Taiga API call) it prints a message to stderr and exits with a non-zero -status. -``` - -- [ ] **Step 4: Update `AGENTS.md`** - -At `AGENTS.md`, in the two `claude mcp add` examples in step 4 (lines ~81-101), append ` serve` to the command in both: - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_USERNAME= \ - -e TAIGA_PASSWORD= \ - -- /absolute/path/to/taiga-mcp-server serve -``` - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_TOKEN= \ - -- /absolute/path/to/taiga-mcp-server serve -``` - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_TOKEN= \ - -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve -``` - -- [ ] **Step 5: Add towncrier changelog fragments** - -Create `changes/14039.feature`: - -``` -Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. -``` - -Create `changes/14039.removal`: - -``` -`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. -``` - -- [ ] **Step 6: Amend the spec's bare-invocation wording for accuracy** - -In `artifacts/specs/2026-08-31-mcp-cli-parity-design.md`, in the "Breaking change" section, replace: - -``` -**This design makes `serve` an explicit, required subcommand** — -bare invocation becomes a Typer usage error. This was a deliberate choice -(matching ring's shape exactly) made during design, not a byproduct. -``` - -with: - -``` -**This design makes `serve` an explicit, required subcommand** — bare -invocation no longer starts the server. With Typer's `no_args_is_help=True` -(the same setting ring-mcp-server's own CLI uses), it shows the command -list/help and exits 0, rather than becoming a hard usage error — the -compatibility break is that it no longer silently defaults to `serve`, not -the exact exit code. This was a deliberate choice (matching ring's shape -exactly) made during design, not a byproduct. -``` - -- [ ] **Step 7: Commit** - -```bash -git add docs/mcp.rst AGENTS.md changes/14039.feature changes/14039.removal artifacts/specs/2026-08-31-mcp-cli-parity-design.md -git commit -m "docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands" -``` - ---- - -## Task 7: Final full-suite regression check - -**Files:** none (verification only). - -- [ ] **Step 1: Run the full test suite** - -Run: `tox -e py313` -Expected: all tests PASS, including every test from Tasks 2–5 and the pre-existing suite (`test_mcp_server.py`, `test_mcp_server_auth.py`, and the rest of the repo's tests untouched by this plan). - -- [ ] **Step 2: Run linting** - -Run: `tox -e ruff,black,isort` (the three lint/format-check envs defined in `tox.ini`) against the full repo. -Expected: no violations on `taiga/mcp_server/cli.py` or `tests/test_mcp_server_cli.py`. If `black`/`isort` report formatting diffs, run `tox -e blacken,isort_format` to auto-fix, then re-run the check envs. - -- [ ] **Step 3: Confirm no unintended changes to untouched files** - -Run: `git diff --stat feature/issue-267-add-mcp..HEAD` -Expected: only the files listed in this plan's "File Structure" table appear. diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md deleted file mode 100644 index af3a047..0000000 --- a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md +++ /dev/null @@ -1,267 +0,0 @@ -# Design: CLI parity between python-taiga's MCP server and ring-mcp-server - -Date: 2026-08-31 -Status: Approved (design phase). Implementation plan to follow in this repo. -Origin: analysis and design were done from the `ring-mcp-server` repository -(comparing this project's `taiga/mcp_server/` against `ring-mcp-server`'s -CLI), then handed off and moved here since this is where the actual -implementation belongs. Taiga: us-14039. GitHub issue: 14039. - -## Context - -`ring-mcp-server` (github.com/nephila/ring_mcp) and this repo's -`taiga/mcp_server/` package are both MCP servers for Nephila tooling, but -architecturally opposite by design: - -- **ring-mcp-server**: generates its entire MCP tool set dynamically at - startup from a bundled OpenAPI 3.0 spec (`ring_mcp/spec.py`, - `ring_mcp/tools.py`). Tool names are the spec's `operationId`s verbatim - (dashes → underscores). This is intentional to that project and out of - scope here. -- **python-taiga** (this repo): hand-implements each of its ~45 (48 - including cross-cutting ones) Taiga operations as an individually - authored `@mcp.tool()`-decorated function in `taiga/mcp_server/server.py`, - using the official MCP SDK's `MCPServer` (`mcp.server.mcpserver`, - `mcp==2.0.0`). Tool name/description/input schema are all derived by the - SDK from the function signature and docstring. **This architecture must - not change** — that was an explicit constraint on this design. - -What differs today, and what this design closes, is the **CLI surface**: -ring-mcp-server exposes its full tool set through a small, fixed set of -generic CLI subcommands usable directly from a shell without an MCP client -(`serve`, `list-tools`, `call --json`, `fetch-token`). -`taiga-mcp-server` today does exactly one thing — start the MCP stdio -server — with no way to list or invoke a tool from a shell at all. - -## Goal - -Give `taiga-mcp-server` the same **CLI verb shape** and **invocation -method** as `ring-mcp-server`, without touching this repo's core -architecture (each Taiga operation stays a hand-written `@mcp.tool()` -function; no dynamic generation is introduced). - -## Non-goals (explicitly out of scope, confirmed during design) - -- **No renaming of existing tools.** The ~45 tool functions - (`list_user_stories`, `get_issue`, `create_task`, etc.) and their - parameters/`ref`-vs-`_by_id` addressing convention are untouched. Parity - is scoped to the CLI verbs and the JSON-blob invocation method only, not - to reshaping tool names toward ring's OpenAPI-operationId-identity style. -- **No `fetch-token` equivalent.** `auth.build_client()` already resolves - username/password to a session token internally and lazily on first tool - call. Taiga JWTs are typically short-lived (per this repo's own - `AGENTS.md`), so a separately printed, exportable token doesn't carry its - weight the way ring's DRF token does. Skipped. -- **No change to `taiga/mcp_server/server.py`'s tool bodies, `auth.py`'s - credential-resolution logic, or `serialize.py`.** This design touches only - `taiga/mcp_server/cli.py` (rewritten) and its tests/docs. - -## Breaking change (must be called out prominently) - -Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio -server. **This design makes `serve` an explicit, required subcommand** — bare -invocation no longer starts the server. With Typer's `no_args_is_help=True` -(the same setting ring-mcp-server's own CLI uses), it shows the command -list/help and exits 0, rather than becoming a hard usage error — the -compatibility break is that it no longer silently defaults to `serve`, not -the exact exit code. This was a deliberate choice (matching ring's shape -exactly) made during design, not a byproduct. - -Impact: every existing MCP client config that invokes the binary with no -arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` -and `uvx --from "python-taiga[mcp]" taiga-mcp-server` examples currently -documented in this repo's own `AGENTS.md`) breaks and must add ` serve`. -This needs: - -- A major-version bump per this repo's own versioning/release mechanism - (`bump-my-version` per one of the branch names seen in `git branch -a` — - confirm exact tool/config during plan execution). -- A prominent breaking-change note in the CHANGELOG/release notes. -- Updated examples in `docs/mcp.rst` and `AGENTS.md` (see "Docs" below). - -## Design - -### 1. CLI structure (Typer) - -Rewrite `taiga/mcp_server/cli.py` from `argparse` to **Typer** (a new -dependency for this repo, chosen deliberately for implementation-style -consistency with ring-mcp-server over keeping argparse, per explicit design -decision — trade-off: one new runtime dependency plus rewriting the existing -flag-parsing logic). - -Three subcommands: - -``` -taiga-mcp-server serve - [--host HOST] [--token TOKEN] [--token-type TYPE] - [--username USER] [--password PASS] [--tls-verify/--no-tls-verify] - - Same auth flags, same env-var fallback (TAIGA_HOST/TAIGA_TOKEN/ - TAIGA_TOKEN_TYPE/TAIGA_USERNAME/TAIGA_PASSWORD/TAIGA_TLS_VERIFY), same - precedence (flag > env > default) as today's argparse implementation. - Calls auth.configure(...), then mcp.run(transport="stdio"). Behavior is - identical to today's default flow — only the verb is new. - -taiga-mcp-server list-tools [--verbose/-v] - [same auth flags as serve, for consistency — list-tools itself never - calls get_client(), so credentials aren't actually required to run it, - but auth.configure() is still invoked for a uniform command surface] - - Default: one line per tool, "name\tdescription", sorted by name. - --verbose: also pretty-prints each tool's JSON input schema. - -taiga-mcp-server call --json/-j '' - [same auth flags as serve — required here since most tools call - get_client()] - - Parses --json (default "{}") as the arguments dict, invokes the named - tool in-process, prints the JSON result to stdout, or an error to - stderr with exit code 1. -``` - -Each subcommand keeps its own copy of the auth option set (via a shared -Typer callback or small options dataclass) rather than global -pre-subcommand flags — idiomatic Typer, and keeps `serve`'s flag behavior -byte-for-byte compatible with today aside from requiring the verb. - -### 2. Invocation mechanics (verified against the installed SDK) - -`mcp.server.mcpserver.MCPServer` (`mcp==2.0.0`) is a distinct, purpose-built -class — not a `FastMCP` alias — exposing async in-process APIs confirmed by -direct inspection/execution against this repo's real `mcp` object -(`taiga.mcp_server.server.mcp`, using the `.tox/py313` env, which has the -`[mcp]` extra installed), with no live MCP client/transport round trip -required: - -```python -async def list_tools(self) -> list[mcp_types.Tool]: ... -async def call_tool(self, name: str, arguments: dict[str, Any], - context=None) -> CallToolResult | InputRequiredResult: ... -``` - -**`list-tools`:** -```python -tools = asyncio.run(mcp.list_tools()) -for t in sorted(tools, key=lambda t: t.name): - dumped = t.model_dump(by_alias=True, exclude_none=True) - print(f"{dumped['name']}\t{dumped.get('description', '')}") - if verbose: - print(json.dumps(dumped["inputSchema"], indent=2)) -``` -`model_dump(by_alias=True, exclude_none=True)` yields the wire-shaped keys -(`name`, `description`, `inputSchema`, `outputSchema`) exactly as an MCP -`ListTools` response would. Verified live: 48 tools registered today, e.g. -```json -{"name": "whoami", "description": "Return the Taiga user currently authenticated.", - "inputSchema": {"properties": {}, "title": "whoamiArguments", "type": "object"}, - "outputSchema": {"additionalProperties": true, "title": "whoamiDictOutput", "type": "object"}} -``` - -**`call`:** -```python -arguments = json.loads(json_str) # malformed JSON -> caught separately, see below -try: - result = asyncio.run(mcp.call_tool(tool_name, arguments)) -except ToolError as e: - ... # see error table below -else: - payload = result.structured_content if result.structured_content is not None else result.content - json.dump(payload, sys.stdout, indent=2, default=str) -``` - -`auth.configure(...)` runs before `asyncio.run(...)`, exactly as `serve` -does today, so `get_client()` inside tool bodies resolves credentials the -same way it does under a real MCP client. - -### 3. Error handling & output contract - -Mirrors ring's stderr-message-plus-`typer.Exit(1)` contract, mapped onto -this repo's actual failure shapes (all verified by direct execution against -the real `mcp` object during design): - -| Failure | Detection | stderr message | -|---|---|---| -| Malformed `--json` | `json.JSONDecodeError` | `Invalid JSON in --json: {exc}` | -| Unknown tool name | `ToolError` message starts with `"Unknown tool: "` | printed as-is | -| Argument validation failure | `ToolError` with `e.__cause__` a `pydantic_core.ValidationError` | `Invalid arguments for {tool_name}: {cause}` | -| Tool raised an application exception (`ConfigError`, `TaigaRestException`, etc.) | `ToolError` with any other `e.__cause__` | `Error calling {tool_name}: {cause}` (fallback to `str(e)` if `__cause__` is `None`) | -| Missing/invalid credentials at `serve`/`call` startup | `ConfigError` from `auth.build_client()` | `{exc}` (message already clear per `auth.py`) | -| Anything from `mcp.shared.exceptions.MCPError` (unwrapped by `call_tool()` per the SDK's own re-raise) | caught for safety even though not expected in normal use | same generic "Error calling {tool_name}: {cause}" formatting | - -All of the above: message to stderr, `raise typer.Exit(1)`. - -Verified failure shapes, captured live against the real `mcp` object -(against `whoami`, an unknown tool, and `get_project` with a missing -required argument): - -```python -await mcp.call_tool("whoami", {}) -# ToolError: "Error executing tool whoami: The Taiga MCP server has not -# been configured with any credentials." -# e.__cause__ -> ConfigError(...) - -await mcp.call_tool("this_tool_does_not_exist", {}) -# ToolError: "Unknown tool: this_tool_does_not_exist" - -await mcp.call_tool("get_project", {}) # missing required "project" arg -# ToolError: "Error executing tool get_project: 1 validation error for -# get_projectArguments ..." -# type(e.__cause__) -> pydantic_core.ValidationError -``` - -On success: `call` prefers `result.structured_content` (populated for every -tool here, since they all return dicts/lists via `to_jsonable()`), falling -back to `result.content` only if `structured_content` is `None`. Verified -live (with `get_client()` stubbed, since no live Taiga credentials were -available during design): -```python -result = await mcp.call_tool("whoami", {}) -# type(result) -> mcp_types._types.CallToolResult -# result.structured_content -> {'id': 1, 'username': 'demo'} -# result.is_error -> False -``` -Written via `json.dump(payload, sys.stdout, indent=2, default=str)`. - -### 4. Testing (scope; exact fixtures/layout to be confirmed against this -repo's existing `tests/` conventions when the plan is written) - -- **`serve`**: port existing argparse-flag-precedence tests to Typer's - `CliRunner`; add a test asserting bare invocation (no subcommand) now - exits non-zero instead of serving. -- **`list-tools`**: all tool names present, sorted; `--verbose` includes - each tool's `inputSchema`; runs without any credentials configured (never - calls `get_client()`). -- **`call`**: success path (stub/monkeypatch `get_client()`, assert stdout - JSON matches the tool's return value); malformed `--json`; unknown tool - name; missing required argument; tool-internal exception (e.g. - unconfigured-credentials `ConfigError`) — each asserting the exact stderr - message and exit code 1. -- No live network/Taiga server needed anywhere — everything runs in-process - against `mcp` with `get_client`/`TaigaAPI` stubbed, as verified during - design. - -### 5. Docs & migration - -- `docs/mcp.rst`: update every example showing bare `taiga-mcp-server` to - `taiga-mcp-server serve`; add a new subsection documenting `list-tools` - and `call`, styled after ring-mcp-server's own usage docs. -- `AGENTS.md`: update the two `claude mcp add ... -- taiga-mcp-server` / - `-- uvx --from "python-taiga[mcp]" taiga-mcp-server` examples (step 4) to - append ` serve`. -- CHANGELOG/release-notes mechanism for this repo (confirm exact convention - during plan execution) documenting the breaking change. - -## Open items for the implementation plan (not blocking this design) - -- Confirm this repo's exact test directory layout/fixtures for - `taiga/mcp_server/` before writing test cases. -- Confirm this repo's exact versioning/changelog mechanism for recording - the breaking change (a `chore/issue-140-switch-to-bump-my-version` branch - was seen in `git branch -a`, suggesting `bump-my-version` — verify). -- Confirm the Typer dependency is added correctly to `setup.cfg`'s `[mcp]` - extras (alongside the existing `mcp~=2.0` pin). -- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on - `feature/issue-267-add-mcp` (where `taiga/mcp_server/` currently lives, - unmerged to `master`) rather than `master` itself, since the package - doesn't exist on `master` yet. Rebase onto `master` once issue-267 merges, - before this branch is itself merged. diff --git a/changes/14039.removal b/changes/14039.removal index 3c2a823..8fba1db 100644 --- a/changes/14039.removal +++ b/changes/14039.removal @@ -1 +1 @@ -`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. `taiga.mcp_server.cli.main()`'s signature also changed, from `main(argv: list[str] | None = None) -> int` to `main() -> None` - this only affects code calling `main()` directly, not the `taiga-mcp-server` console script. diff --git a/docs/mcp.rst b/docs/mcp.rst index 8277d31..f416266 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -34,7 +34,7 @@ Any of the following also work, depending on your toolchain: 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 + uvx --from "python-taiga[mcp]" taiga-mcp-server --help # no persistent install at all Any of these makes a ``taiga-mcp-server`` console script available. diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 75aab3d..92a88c3 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -11,12 +11,17 @@ import typer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import MCPError -from pydantic_core import ValidationError as PydanticValidationError +from pydantic import ValidationError as PydanticValidationError from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure -app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") +app = typer.Typer( + add_completion=False, + no_args_is_help=True, + help="Taiga MCP server & CLI. Prefer TAIGA_TOKEN/TAIGA_PASSWORD env vars over " + "--token/--password, which can be visible in the process list.", +) def _version_callback(value: bool) -> None: @@ -128,7 +133,11 @@ def call( password: str | None = PasswordOption, tls_verify: bool | None = TlsVerifyOption, ) -> None: - """Call a single tool directly, bypassing an MCP client.""" + """Call a single tool directly, bypassing an MCP client. + + Prefer the TAIGA_TOKEN/TAIGA_PASSWORD environment variables over + --token/--password, which can be visible in the process list. + """ try: parsed_arguments = json.loads(arguments) except json.JSONDecodeError as exc: diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 2174d66..f7dd9f9 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -192,6 +192,7 @@ def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): result = runner.invoke(cli.app, []) assert "serve" in result.output + assert result.exit_code != 0 mock_configure.assert_not_called() mock_mcp.run.assert_not_called() From 07281c4c86639dd470cecbe3cb3046bbf6c0ad8f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 5 Sep 2026 16:26:50 +0200 Subject: [PATCH 23/32] fix(mcp): address review findings on get_history args, changelog, serialize tests - Reorder get_history(entity_type, ref, project=None) so project is a true optional MCP argument, matching the documented "omit project for wiki" usage instead of requiring project: null. - Fold the list-tools/call CLI addition into the 267.feature fragment and drop the 14039.feature/14039.removal fragments: issue #14039 doesn't exist in this repo, and the removal note described migrating away from a bare-command invocation this package never released. - Add tests/test_mcp_server_serialize.py covering to_jsonable's InstanceResource branch with real parsed models: recursion into nested resources and lists, requester skipping, and date/datetime conversion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qc6iG6zXxqiMJUnsuAz32g --- changes/14039.feature | 1 - changes/14039.removal | 1 - changes/267.feature | 2 +- taiga/mcp_server/server.py | 2 +- tests/test_mcp_server.py | 8 ++-- tests/test_mcp_server_serialize.py | 75 ++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 8 deletions(-) delete mode 100644 changes/14039.feature delete mode 100644 changes/14039.removal create mode 100644 tests/test_mcp_server_serialize.py diff --git a/changes/14039.feature b/changes/14039.feature deleted file mode 100644 index 22cf153..0000000 --- a/changes/14039.feature +++ /dev/null @@ -1 +0,0 @@ -Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. diff --git a/changes/14039.removal b/changes/14039.removal deleted file mode 100644 index 8fba1db..0000000 --- a/changes/14039.removal +++ /dev/null @@ -1 +0,0 @@ -`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. `taiga.mcp_server.cli.main()`'s signature also changed, from `main(argv: list[str] | None = None) -> int` to `main() -> None` - this only affects code calling `main()` directly, not the `taiga-mcp-server` console script. diff --git a/changes/267.feature b/changes/267.feature index 4d2b979..81bb943 100644 --- a/changes/267.feature +++ b/changes/267.feature @@ -1 +1 @@ -Add MCP server exposing Taiga projects, user stories, tasks, issues, epics, milestones and wiki pages as tools for AI agents +Add MCP server exposing Taiga projects, user stories, tasks, issues, epics, milestones and wiki pages as tools for AI agents. `taiga-mcp-server` also gains `list-tools` and `call` subcommands, letting tools be listed and invoked directly from a shell without an MCP client. diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index f1eba6d..8215c1d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -169,8 +169,8 @@ def add_comment_by_id( @mcp.tool() def get_history( entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], - project: str | int | None, ref: int, + project: str | int | None = None, ) -> list[dict[str, Any]]: """Get the full change/comment history of a user story, task, issue, epic or wiki page. diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 30918c3..db49d8e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -281,7 +281,7 @@ def test_get_history_resolves_ref_for_non_wiki_types(mock_get_client): mock_client.history.user_story.get.return_value = [_HISTORY_ENTRY] mock_get_client.return_value = mock_client - result = server.get_history("user_story", 1, 45634) + result = server.get_history("user_story", 45634, 1) mock_project.get_userstory_by_ref.assert_called_once_with(45634) mock_client.history.user_story.get.assert_called_once_with(99) @@ -300,7 +300,7 @@ def test_get_history_routes_every_ref_entity_type(mock_get_client): getattr(mock_project, method_name).return_value = resolved getattr(mock_client.history, entity_type).get.return_value = [] - result = server.get_history(entity_type, 1, 45634) + result = server.get_history(entity_type, 45634, 1) getattr(mock_project, method_name).assert_called_once_with(45634) getattr(mock_client.history, entity_type).get.assert_called_once_with(1) @@ -313,7 +313,7 @@ def test_get_history_wiki_uses_literal_id(mock_get_client): mock_client.history.wiki.get.return_value = [_HISTORY_ENTRY] mock_get_client.return_value = mock_client - result = server.get_history("wiki", None, 1) + result = server.get_history("wiki", 1) mock_client.history.wiki.get.assert_called_once_with(1) mock_client.projects.get.assert_not_called() @@ -322,7 +322,7 @@ def test_get_history_wiki_uses_literal_id(mock_get_client): def test_get_history_requires_project_for_non_wiki(): with pytest.raises(ValueError, match="project"): - server.get_history("issue", None, 1) + server.get_history("issue", 1) @patch("taiga.mcp_server.server.get_client") diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py new file mode 100644 index 0000000..fedff0d --- /dev/null +++ b/tests/test_mcp_server_serialize.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import datetime +from unittest.mock import MagicMock + +from taiga.mcp_server.serialize import to_jsonable +from taiga.models.base import InstanceResource + + +def _make_resource(**params): + """Build a real InstanceResource the way python-taiga parses an API response.""" + return InstanceResource(MagicMock(name="requester"), **params) + + +def test_to_jsonable_converts_instance_resource_to_dict(): + resource = _make_resource(id=1, subject="hello") + + result = to_jsonable(resource) + + assert result == {"id": 1, "subject": "hello"} + + +def test_to_jsonable_skips_requester(): + resource = _make_resource(id=1) + + result = to_jsonable(resource) + + assert "requester" not in result + + +def test_to_jsonable_recurses_into_nested_instance_resource(): + owner = _make_resource(id=7, full_name="Alice") + resource = _make_resource(id=1, owner=owner) + + result = to_jsonable(resource) + + assert result == {"id": 1, "owner": {"id": 7, "full_name": "Alice"}} + + +def test_to_jsonable_recurses_into_list_of_instance_resources(): + members = [_make_resource(id=1), _make_resource(id=2)] + resource = _make_resource(id=99, members=members) + + result = to_jsonable(resource) + + assert result == {"id": 99, "members": [{"id": 1}, {"id": 2}]} + + +def test_to_jsonable_converts_dates_parsed_by_instance_resource(): + # InstanceResource.__init__ parses created_date/modified_date matching this exact + # Taiga API format into real datetime objects - use that format here so the + # attribute is an actual datetime, not a string, when it reaches to_jsonable. + resource = _make_resource(id=1, created_date="2026-08-20T10:00:00+0000") + + assert isinstance(resource.created_date, datetime.datetime) + + result = to_jsonable(resource) + + assert result == {"id": 1, "created_date": resource.created_date.isoformat()} + + +def test_to_jsonable_converts_plain_date_and_datetime_values(): + resource = _make_resource( + id=1, + due_date=datetime.date(2026, 1, 1), + finished_at=datetime.datetime(2026, 1, 1, 12, 30, tzinfo=datetime.UTC), + ) + + result = to_jsonable(resource) + + assert result == { + "id": 1, + "due_date": "2026-01-01", + "finished_at": "2026-01-01T12:30:00+00:00", + } From fa99730d4822dd9d4a0611c829c89acd1c111d75 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 5 Sep 2026 16:35:56 +0200 Subject: [PATCH 24/32] Modify cache key in lint workflow for tox Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/lint.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 73a826d..e05667f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,12 +30,9 @@ 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') }} + # Include every dependency declaration in the exact cache key so dependency + # changes produce a clean tox environment without relying on partial restores. + key: ${{ runner.os }}-lint-${{ matrix.toxenv }}-${{ hashFiles('setup.cfg', 'tox.ini', 'requirements*.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools tox>4 From 0111088c040ebf651e26c593cea10a0e3602fc8e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 5 Sep 2026 16:36:10 +0200 Subject: [PATCH 25/32] Modify cache key in GitHub Actions workflow Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f90ca61..d47fecf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,12 +26,9 @@ 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') }} + # Include every dependency declaration in the exact cache key so dependency + # changes produce a clean tox environment without relying on partial restores. + key: ${{ runner.os }}-tox-${{ format('{{py{0}}}', matrix.python-version) }}-${{ hashFiles('setup.cfg', 'tox.ini', 'requirements*.txt') }} - name: Install dependencies run: | sudo apt-get install gettext From 62ea7d10969b013b9cb730dc8174f6439663db9f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 5 Sep 2026 16:36:32 +0200 Subject: [PATCH 26/32] Fix command to serve taiga-mcp-server Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/mcp.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index f416266..9878bd8 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -144,7 +144,7 @@ available in every project: -e TAIGA_HOST=https://taiga.example.com \ -e TAIGA_USERNAME=myuser \ -e TAIGA_PASSWORD=mypassword \ - -- taiga-mcp-server serve + -- "$(command -v taiga-mcp-server)" serve ``--scope user`` stores the registration in your own Claude configuration, not in any particular project. Check it went through with: From 2934839b8dd3891ee1554b45a4d1d7e498eb95b5 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 03:50:21 +0200 Subject: [PATCH 27/32] feat(mcp): add custom-attribute value get/set tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- taiga/mcp_server/server.py | 70 ++++++++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 8215c1d..759bb10 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -204,6 +204,76 @@ def get_history_by_id( return to_jsonable(getattr(client.history, entity_type).get(id)) +@mcp.tool() +def get_custom_attributes_values( + entity_type: Literal["user_story", "task", "issue", "epic"], + project: str | int, + ref: int, +) -> dict[str, Any]: + """Get the custom-attribute values of a user story, task, issue or epic, + identified by its per-project ref number. Keys of `attributes_values` are + attribute ids as strings - see get_project's `*_custom_attributes` lists + for id -> name. The returned `version` belongs to this custom-attributes- + values resource, a separate version sequence from the entity's own + `version` field - pass it back to `set_custom_attribute_value`, not the + entity's version. + """ + resource = _get_by_ref(entity_type, project, ref) + return to_jsonable(resource.get_attributes()) + + +@mcp.tool() +def get_custom_attributes_values_by_id( + entity_type: Literal["user_story", "task", "issue", "epic"], id: int # noqa: A002 +) -> dict[str, Any]: + """Get custom-attribute values by database id. + + Secondary lookup: prefer `get_custom_attributes_values` with a project + ref. + 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.get_attributes()) + + +@mcp.tool() +def set_custom_attribute_value( + entity_type: Literal["user_story", "task", "issue", "epic"], + project: str | int, + ref: int, + attribute_id: int, + value: Any, + version: int, +) -> dict[str, Any]: + """Set one custom-attribute value on a user story, task, issue or epic, + identified by its per-project ref number. `attribute_id` is the numeric id + from get_project's `*_custom_attributes` list (e.g. the "Code" attribute). + `version` is the custom-attributes-values resource's own version (from a + prior get_custom_attributes_values call, or 1 if never set before) - not + the entity's own `version` field. + """ + resource = _get_by_ref(entity_type, project, ref) + return to_jsonable(resource.set_attribute(attribute_id, value, version=version)) + + +@mcp.tool() +def set_custom_attribute_value_by_id( + entity_type: Literal["user_story", "task", "issue", "epic"], + id: int, # noqa: A002 + attribute_id: int, + value: Any, + version: int, +) -> dict[str, Any]: + """Set a custom-attribute value by database id. + + Secondary lookup: prefer `set_custom_attribute_value` with a project + ref. + 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.set_attribute(attribute_id, value, version=version)) + + # --- User stories ----------------------------------------------------------------- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index db49d8e..5f59615 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -337,6 +337,76 @@ def test_get_history_by_id_routes_every_entity_type(mock_get_client): assert result == [] +# --- custom attribute values ------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_get_custom_attributes_values_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.get_attributes.return_value = {"attributes_values": {"1": "x"}, "version": 1} + + result = server.get_custom_attributes_values(entity_type, 1, 45634) + + getattr(mock_project, method_name).assert_called_once_with(45634) + resource.get_attributes.assert_called_once_with() + assert result == {"attributes_values": {"1": "x"}, "version": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_custom_attributes_values_by_id_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.get_attributes.return_value = {"attributes_values": {"1": "x"}, "version": 1} + + result = server.get_custom_attributes_values_by_id(entity_type, 1) + + getattr(mock_client, attr).get.assert_called_once_with(1) + assert result == {"attributes_values": {"1": "x"}, "version": 1} + + +@patch("taiga.mcp_server.server.get_client") +def test_set_custom_attribute_value_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.set_attribute.return_value = {"attributes_values": {"10": "NPH-INT"}, "version": 2} + + result = server.set_custom_attribute_value(entity_type, 1, 45634, 10, "NPH-INT", 1) + + getattr(mock_project, method_name).assert_called_once_with(45634) + resource.set_attribute.assert_called_once_with(10, "NPH-INT", version=1) + assert result == {"attributes_values": {"10": "NPH-INT"}, "version": 2} + + +@patch("taiga.mcp_server.server.get_client") +def test_set_custom_attribute_value_by_id_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.set_attribute.return_value = {"attributes_values": {"10": "NPH-INT"}, "version": 2} + + result = server.set_custom_attribute_value_by_id(entity_type, 1, 10, "NPH-INT", 1) + + getattr(mock_client, attr).get.assert_called_once_with(1) + resource.set_attribute.assert_called_once_with(10, "NPH-INT", version=1) + assert result == {"attributes_values": {"10": "NPH-INT"}, "version": 2} + + # --- User stories ----------------------------------------------------------------------- From 7611ef5652d1ea417b143f9f96453c0cdff2374b Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 03:54:13 +0200 Subject: [PATCH 28/32] feat(mcp): add list_memberships tool Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- taiga/mcp_server/server.py | 13 +++++++++++++ taiga/models/models.py | 6 ++++-- tests/test_mcp_server.py | 26 ++++++++++++++++++++++++++ tests/test_projects.py | 3 +++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 759bb10..aad54ee 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -135,6 +135,19 @@ def search(project: str | int, text: str = "") -> dict[str, Any]: } +@mcp.tool() +def list_memberships(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List a project's memberships (username, full_name, user_email, role_name, etc.) - + the pool of users assignable as owner/assigned_to/watcher on that project's items. + + Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ + `page_size` to page further. + """ + proj = _resolve_project(project) + query = _paginated(dict(filters or {})) + return to_jsonable(proj.list_memberships(**query)) + + @mcp.tool() def add_comment( entity_type: Literal["user_story", "task", "issue", "epic"], project: str | int, ref: int, comment: str diff --git a/taiga/models/models.py b/taiga/models/models.py index 884477e..7590791 100644 --- a/taiga/models/models.py +++ b/taiga/models/models.py @@ -1356,11 +1356,13 @@ def add_membership(self, email, role, **attrs): """ return Memberships(self.requester).create(self.id, email, role, **attrs) - def list_memberships(self): + def list_memberships(self, **queryparams): """ Get the list of :class:`Membership` resources for the project. + + :param queryparams: optional query parameters (e.g. `page`, `page_size`) """ - return Memberships(self.requester).list(project=self.id) + return Memberships(self.requester).list(project=self.id, **queryparams) def add_user_story(self, subject, **attrs): """ diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 5f59615..04a9523 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -230,6 +230,32 @@ def test_search(mock_get_client): } +# --- memberships -------------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server._resolve_project") +def test_list_memberships_no_filters(mock_resolve_project): + mock_project = MagicMock() + mock_project.list_memberships.return_value = [{"username": "yakky", "user_email": "i.spalletti@nephila.digital"}] + mock_resolve_project.return_value = mock_project + + result = server.list_memberships(1) + + mock_project.list_memberships.assert_called_once_with(page=1, page_size=100) + assert result == [{"username": "yakky", "user_email": "i.spalletti@nephila.digital"}] + + +@patch("taiga.mcp_server.server._resolve_project") +def test_list_memberships_with_filters(mock_resolve_project): + mock_project = MagicMock() + mock_project.list_memberships.return_value = [] + mock_resolve_project.return_value = mock_project + + server.list_memberships(1, filters={"page": 2}) + + mock_project.list_memberships.assert_called_once_with(page=2, page_size=100) + + # --- add_comment --------------------------------------------------------------------------- diff --git a/tests/test_projects.py b/tests/test_projects.py index edd3342..12a512d 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -508,6 +508,9 @@ def test_list_membership(self, mock_list_memberships): project.list_memberships() mock_list_memberships.assert_called_with(project=1) + project.list_memberships(page=2, page_size=50) + mock_list_memberships.assert_called_with(project=1, page=2, page_size=50) + @patch("taiga.models.Webhooks.create") def test_add_webhook(self, mock_new_webhook): rm = RequestMaker("/api/v1", "fakehost", "faketoken") From e1db218e3dd3558b3d945ff3e52752c7913f8465 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 03:57:38 +0200 Subject: [PATCH 29/32] feat(mcp): add epic-user-story linking tools - Add Epic.add_related_user_story() model method to link user stories to epics - Add link_epic_user_story() MCP tool for ref-based linking - Add link_epic_user_story_by_id() MCP tool for ID-based linking - Add comprehensive tests for all three new capabilities Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- taiga/mcp_server/server.py | 20 ++++++++++++++++++++ taiga/models/models.py | 13 +++++++++++++ tests/test_epics.py | 17 +++++++++++++++++ tests/test_mcp_server.py | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index aad54ee..974066d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -591,6 +591,26 @@ def delete_epic_by_id(id: int) -> dict[str, str]: # noqa: A002 return {"status": "deleted", "id": str(id)} +@mcp.tool() +def link_epic_user_story(project: str | int, epic_ref: int, user_story_ref: int) -> dict[str, Any]: + """Link a user story to an epic, identifying both by their per-project ref numbers.""" + proj = _resolve_project(project) + epic = proj.get_epic_by_ref(epic_ref) + user_story = proj.get_userstory_by_ref(user_story_ref) + return to_jsonable(epic.add_related_user_story(user_story.id)) + + +@mcp.tool() +def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, Any]: + """Link a user story to an epic by their database ids. + + Secondary lookup: prefer `link_epic_user_story` with a project + ref numbers. + """ + client = get_client() + epic = client.epics.get(epic_id) + return to_jsonable(epic.add_related_user_story(user_story_id)) + + # --- Milestones (sprints) ----------------------------------------------------------------- diff --git a/taiga/models/models.py b/taiga/models/models.py index 7590791..939df41 100644 --- a/taiga/models/models.py +++ b/taiga/models/models.py @@ -328,6 +328,19 @@ def list_user_stories(self, **queryparams): """ return UserStories(self.requester).list(epic=self.id, **queryparams) + def add_related_user_story(self, user_story_id, **attrs): + """ + Link an existing :class:`UserStory` to this epic. + + :param user_story_id: id of the :class:`UserStory` to link + :param attrs: other optional attributes of the relation + """ + attrs.update({"user_story": user_story_id}) + response = self.requester.post( + "/{endpoint}/{id}/related_userstories", endpoint=self.endpoint, id=self.id, payload=attrs + ) + return response.json() + def list_attachments(self): """ Get a list of :class:`EpicAttachment`. diff --git a/tests/test_epics.py b/tests/test_epics.py index 3221500..acd3e72 100644 --- a/tests/test_epics.py +++ b/tests/test_epics.py @@ -83,3 +83,20 @@ def test_add_comment(self, mock_update): epic = Epic(rm, id=1) epic.add_comment("hola") mock_update.assert_called_with(comment="hola") + + +@patch("taiga.requestmaker.RequestMaker.post") +def test_add_related_user_story(mock_requestmaker_post): + mock_requestmaker_post.return_value = MockResponse(200, '{"id": 5, "epic": 1, "user_story": 10}') + rm = RequestMaker("/api/v1", "fakehost", "faketoken") + epic = Epic(rm, id=1) + + result = epic.add_related_user_story(10) + + mock_requestmaker_post.assert_called_with( + "/{endpoint}/{id}/related_userstories", + endpoint=Epic.endpoint, + id=epic.id, + payload={"user_story": 10}, + ) + assert result == {"id": 5, "epic": 1, "user_story": 10} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 04a9523..17e767b 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -939,6 +939,44 @@ def test_delete_epic_by_id(mock_get_client): assert result == {"status": "deleted", "id": "1"} +# --- epic/user-story linking ----------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_link_epic_user_story(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_epic = MagicMock(id=1) + mock_us = MagicMock(id=10) + mock_project.get_epic_by_ref.return_value = mock_epic + mock_project.get_userstory_by_ref.return_value = mock_us + mock_epic.add_related_user_story.return_value = {"id": 5, "epic": 1, "user_story": 10} + mock_get_client.return_value = mock_client + + result = server.link_epic_user_story(1, 42, 45634) + + mock_project.get_epic_by_ref.assert_called_once_with(42) + mock_project.get_userstory_by_ref.assert_called_once_with(45634) + mock_epic.add_related_user_story.assert_called_once_with(10) + assert result == {"id": 5, "epic": 1, "user_story": 10} + + +@patch("taiga.mcp_server.server.get_client") +def test_link_epic_user_story_by_id(mock_get_client): + mock_client = MagicMock() + mock_epic = MagicMock() + mock_client.epics.get.return_value = mock_epic + mock_epic.add_related_user_story.return_value = {"id": 5, "epic": 1, "user_story": 10} + mock_get_client.return_value = mock_client + + result = server.link_epic_user_story_by_id(1, 10) + + mock_client.epics.get.assert_called_once_with(1) + mock_epic.add_related_user_story.assert_called_once_with(10) + assert result == {"id": 5, "epic": 1, "user_story": 10} + + # --- Milestones ------------------------------------------------------------------------ From ec4f07cf4a48796d51fb208c87e48aa38d5655ca Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 04:00:26 +0200 Subject: [PATCH 30/32] docs(mcp): note custom-attribute/membership/epic-link tools in changelog Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- changes/267.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/267.feature b/changes/267.feature index 81bb943..19e3984 100644 --- a/changes/267.feature +++ b/changes/267.feature @@ -1 +1 @@ -Add MCP server exposing Taiga projects, user stories, tasks, issues, epics, milestones and wiki pages as tools for AI agents. `taiga-mcp-server` also gains `list-tools` and `call` subcommands, letting tools be listed and invoked directly from a shell without an MCP client. +Add MCP server exposing Taiga projects, user stories, tasks, issues, epics, milestones and wiki pages as tools for AI agents. `taiga-mcp-server` also gains `list-tools` and `call` subcommands, letting tools be listed and invoked directly from a shell without an MCP client. The server also exposes custom-attribute value read/write, project membership listing, and epic-user-story linking. From 10f289596e4f3b4f567411b986aa7aed56176aab Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 04:07:12 +0200 Subject: [PATCH 31/32] docs(mcp): document custom-attribute, membership, and epic-link tools Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- docs/mcp.rst | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/mcp.rst b/docs/mcp.rst index 9878bd8..3be3a61 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -168,6 +168,11 @@ Available tools ``search`` Search user stories, tasks, issues, epics and wiki pages in a project. +``list_memberships`` + List a project's memberships (username, full_name, user_email, role_name, + etc.) - the pool of users assignable as owner/assigned_to/watcher on that + project's items. + ``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 @@ -181,6 +186,23 @@ Available tools in Taiga, so for ``entity_type="wiki"`` pass the page's database id as ``ref`` and omit ``project``. +``get_custom_attributes_values`` / ``get_custom_attributes_values_by_id`` + Get the custom-attribute values of a user story, task, issue or epic. + Keys of ``attributes_values`` are attribute ids as strings - see + ``get_project``'s ``*_custom_attributes`` lists for id -> name. + +``set_custom_attribute_value`` / ``set_custom_attribute_value_by_id`` + Set one custom-attribute value on a user story, task, issue or epic. + ``attribute_id`` is the numeric id from ``get_project``'s + ``*_custom_attributes`` list. + +.. important:: The ``version`` returned by ``get_custom_attributes_values`` + (and expected by ``set_custom_attribute_value``) belongs to that + custom-attributes-values resource - a separate version sequence + from the entity's own ``version`` field. Always pass back the + version from a prior ``get_custom_attributes_values`` call (or + ``1`` if never set before), not the entity's own ``version``. + ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -193,6 +215,10 @@ Available tools ``list_epics``, ``get_epic``, ``create_epic``, ``update_epic``, ``delete_epic`` Manage epics. +``link_epic_user_story`` / ``link_epic_user_story_by_id`` + Link a user story to an epic, identifying both by their per-project ref + numbers (primary) or by database id (secondary, see below). + .. 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 @@ -236,3 +262,7 @@ 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. + +``set_custom_attribute_value``/``set_custom_attribute_value_by_id`` and +``link_epic_user_story``/``link_epic_user_story_by_id`` are also writes and +fall under the same destructive-tools framing above. From 07a59ff61540cb31f5557d06db9f0aff128337ca Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 04:23:22 +0200 Subject: [PATCH 32/32] docs: add usage example for linking a user story to an epic Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Fe8Yh4cseFguoEFxnHXFn3 --- docs/usage.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/usage.rst b/docs/usage.rst index f56bb85..c0ef662 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -202,6 +202,15 @@ Create an issue description='Bug #5' ) +****************************************************** +Link a user story to an epic +****************************************************** + +.. code:: python + + epic = new_project.add_epic('New Epic') + epic.add_related_user_story(userstory.id) + ****************************************************** Create a custom attribute ******************************************************