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/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/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/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. diff --git a/setup.cfg b/setup.cfg index 4da9c36..b31e0db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,21 +30,32 @@ install_requires = six>=1.9 python-dateutil>=2.4 pyjwkest>=1.0 -packages = taiga +packages = find: python_requires = >=3.7 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)) diff --git a/tox.ini b/tox.ini index 34e715e..e0f908a 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