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 diff --git a/.gitignore b/.gitignore index ba6122a..3dff66b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ debian/files debian/python-taiga* debian/python3-taiga* .ruff_cache +.venv +*.egg-link +.superpowers diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53d6f38 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# 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://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 + `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. + + **Default to username/password over a token, unless the instance has a + real personal-access-token feature.** Stock Taiga (checked against + `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 + 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: + ```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 + ``` + 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 + ``` + 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://my.taiga.com \ + -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. 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/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. diff --git a/changes/267.feature b/changes/267.feature new file mode 100644 index 0000000..4d2b979 --- /dev/null +++ b/changes/267.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/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..dd2b926 --- /dev/null +++ b/docs/mcp.rst @@ -0,0 +1,210 @@ +.. :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 the official `MCP Python SDK +`_ (``mcp``) 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_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_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. 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. + +``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. + +.. 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). + +``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. + +.. 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 +**************** + +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. 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/setup.cfg b/setup.cfg index c85baca..2aef4db 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 = + mcp~=2.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..f1eba6d --- /dev/null +++ b/taiga/mcp_server/server.py @@ -0,0 +1,591 @@ +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +from typing import Any, Literal + +from mcp.server.mcpserver import MCPServer + +from .auth import get_client +from .serialize import to_jsonable + +mcp = MCPServer( + 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", +} + +_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(): + return int(project) + client = get_client() + 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 + + +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. + + `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.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 + + +@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. + + 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(**_paginated(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"], 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) + resource.add_comment(comment) + return {"status": "commented", "ref": str(ref), "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 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) + resource.add_comment(comment) + return {"status": "commented", "id": str(id), "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"], + 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)) + + +# --- 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. + + 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(**_paginated(query))) + + +@mcp.tool() +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)) + + +@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(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) + 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.""" + 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() +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)} + + +# --- 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. + + 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(**_paginated(query))) + + +@mcp.tool() +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)) + + +@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(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) + 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.""" + client = get_client() + resource = client.tasks.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.tasks.get(id)) + + +@mcp.tool() +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)} + + +# --- 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. + + 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(**_paginated(query))) + + +@mcp.tool() +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)) + + +@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(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) + 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.""" + client = get_client() + resource = client.issues.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.issues.get(id)) + + +@mcp.tool() +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)} + + +# --- 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. + + 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(**_paginated(query))) + + +@mcp.tool() +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)) + + +@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(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) + 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.""" + client = get_client() + resource = client.epics.get(id) + resource.patch(list(fields.keys()), **fields) + return to_jsonable(client.epics.get(id)) + + +@mcp.tool() +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)} + + +# --- 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. + + 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(**_paginated(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. + + 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(**_paginated(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.""" + # 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 new file mode 100644 index 0000000..30918c3 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,946 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, call, patch + +import pytest + +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") + + +# --- _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 --------------------------------------------------------------------------- + + +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} + + +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 ---------------------------------------------------------- + + +@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): + # 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 + 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 + + 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 == {"status": "commented", "ref": "45634", "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 + + for entity_type, attr in server._ENTITY_ATTR.items(): + resource = getattr(mock_client, attr).get.return_value + + 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 == {"status": "commented", "id": "1", "comment": "hello"} + + +# --- get_history ----------------------------------------------------------------------- + + +@patch("taiga.mcp_server.server.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", 1, 45634) + + 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_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, 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, 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 == [] + + +# --- 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_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_by_id(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): + # 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(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(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_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"} + + +@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, 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"} + + +# --- 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_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_by_id(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_project = MagicMock() + mock_client.projects.get.return_value = mock_project + 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(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_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"} + + +@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, 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"} + + +# --- 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_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_by_id(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_project = MagicMock() + mock_client.projects.get.return_value = mock_project + 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(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_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"} + + +@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_by_id(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_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_by_id(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_project = MagicMock() + mock_client.projects.get.return_value = mock_project + 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(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_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"} + + +@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_by_id(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(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_has_calls([call(1), call(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 diff --git a/tox.ini b/tox.ini index 9b31b0a..4a2fb8d 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 @@ -97,6 +108,7 @@ ignore = tasks.py tests/** debian/** + artifacts/** *.mo ignore-bad-ideas = *.mo