From fcc7255ce64f8946a96d71dfbfd7d732816cd1b9 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:25:15 +0200 Subject: [PATCH 01/10] docs(mcp): add design for CLI parity with ring-mcp-server Analysis comparing this repo's hand-implemented, SDK-driven MCP tool set against ring-mcp-server's dynamic OpenAPI-generated one, and an approved design closing the CLI/invocation-method gap: add serve, list-tools, and call --json subcommands to taiga-mcp-server via a Typer rewrite of cli.py, without touching tool architecture. Breaking change flagged: bare 'taiga-mcp-server' will require an explicit 'serve' subcommand going forward. GitHub issue: 14039 --- .../specs/2026-08-31-mcp-cli-parity-design.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 artifacts/specs/2026-08-31-mcp-cli-parity-design.md diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md new file mode 100644 index 0000000..60a962d --- /dev/null +++ b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md @@ -0,0 +1,263 @@ +# Design: CLI parity between python-taiga's MCP server and ring-mcp-server + +Date: 2026-08-31 +Status: Approved (design phase). Implementation plan to follow in this repo. +Origin: analysis and design were done from the `ring-mcp-server` repository +(comparing this project's `taiga/mcp_server/` against `ring-mcp-server`'s +CLI), then handed off and moved here since this is where the actual +implementation belongs. Taiga: us-14039. GitHub issue: 14039. + +## Context + +`ring-mcp-server` (github.com/nephila/ring_mcp) and this repo's +`taiga/mcp_server/` package are both MCP servers for Nephila tooling, but +architecturally opposite by design: + +- **ring-mcp-server**: generates its entire MCP tool set dynamically at + startup from a bundled OpenAPI 3.0 spec (`ring_mcp/spec.py`, + `ring_mcp/tools.py`). Tool names are the spec's `operationId`s verbatim + (dashes → underscores). This is intentional to that project and out of + scope here. +- **python-taiga** (this repo): hand-implements each of its ~45 (48 + including cross-cutting ones) Taiga operations as an individually + authored `@mcp.tool()`-decorated function in `taiga/mcp_server/server.py`, + using the official MCP SDK's `MCPServer` (`mcp.server.mcpserver`, + `mcp==2.0.0`). Tool name/description/input schema are all derived by the + SDK from the function signature and docstring. **This architecture must + not change** — that was an explicit constraint on this design. + +What differs today, and what this design closes, is the **CLI surface**: +ring-mcp-server exposes its full tool set through a small, fixed set of +generic CLI subcommands usable directly from a shell without an MCP client +(`serve`, `list-tools`, `call --json`, `fetch-token`). +`taiga-mcp-server` today does exactly one thing — start the MCP stdio +server — with no way to list or invoke a tool from a shell at all. + +## Goal + +Give `taiga-mcp-server` the same **CLI verb shape** and **invocation +method** as `ring-mcp-server`, without touching this repo's core +architecture (each Taiga operation stays a hand-written `@mcp.tool()` +function; no dynamic generation is introduced). + +## Non-goals (explicitly out of scope, confirmed during design) + +- **No renaming of existing tools.** The ~45 tool functions + (`list_user_stories`, `get_issue`, `create_task`, etc.) and their + parameters/`ref`-vs-`_by_id` addressing convention are untouched. Parity + is scoped to the CLI verbs and the JSON-blob invocation method only, not + to reshaping tool names toward ring's OpenAPI-operationId-identity style. +- **No `fetch-token` equivalent.** `auth.build_client()` already resolves + username/password to a session token internally and lazily on first tool + call. Taiga JWTs are typically short-lived (per this repo's own + `AGENTS.md`), so a separately printed, exportable token doesn't carry its + weight the way ring's DRF token does. Skipped. +- **No change to `taiga/mcp_server/server.py`'s tool bodies, `auth.py`'s + credential-resolution logic, or `serialize.py`.** This design touches only + `taiga/mcp_server/cli.py` (rewritten) and its tests/docs. + +## Breaking change (must be called out prominently) + +Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio +server. **This design makes `serve` an explicit, required subcommand** — +bare invocation becomes a Typer usage error. This was a deliberate choice +(matching ring's shape exactly) made during design, not a byproduct. + +Impact: every existing MCP client config that invokes the binary with no +arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` +and `uvx --from "python-taiga[mcp]" taiga-mcp-server` examples currently +documented in this repo's own `AGENTS.md`) breaks and must add ` serve`. +This needs: + +- A major-version bump per this repo's own versioning/release mechanism + (`bump-my-version` per one of the branch names seen in `git branch -a` — + confirm exact tool/config during plan execution). +- A prominent breaking-change note in the CHANGELOG/release notes. +- Updated examples in `docs/mcp.rst` and `AGENTS.md` (see "Docs" below). + +## Design + +### 1. CLI structure (Typer) + +Rewrite `taiga/mcp_server/cli.py` from `argparse` to **Typer** (a new +dependency for this repo, chosen deliberately for implementation-style +consistency with ring-mcp-server over keeping argparse, per explicit design +decision — trade-off: one new runtime dependency plus rewriting the existing +flag-parsing logic). + +Three subcommands: + +``` +taiga-mcp-server serve + [--host HOST] [--token TOKEN] [--token-type TYPE] + [--username USER] [--password PASS] [--tls-verify/--no-tls-verify] + + Same auth flags, same env-var fallback (TAIGA_HOST/TAIGA_TOKEN/ + TAIGA_TOKEN_TYPE/TAIGA_USERNAME/TAIGA_PASSWORD/TAIGA_TLS_VERIFY), same + precedence (flag > env > default) as today's argparse implementation. + Calls auth.configure(...), then mcp.run(transport="stdio"). Behavior is + identical to today's default flow — only the verb is new. + +taiga-mcp-server list-tools [--verbose/-v] + [same auth flags as serve, for consistency — list-tools itself never + calls get_client(), so credentials aren't actually required to run it, + but auth.configure() is still invoked for a uniform command surface] + + Default: one line per tool, "name\tdescription", sorted by name. + --verbose: also pretty-prints each tool's JSON input schema. + +taiga-mcp-server call --json/-j '' + [same auth flags as serve — required here since most tools call + get_client()] + + Parses --json (default "{}") as the arguments dict, invokes the named + tool in-process, prints the JSON result to stdout, or an error to + stderr with exit code 1. +``` + +Each subcommand keeps its own copy of the auth option set (via a shared +Typer callback or small options dataclass) rather than global +pre-subcommand flags — idiomatic Typer, and keeps `serve`'s flag behavior +byte-for-byte compatible with today aside from requiring the verb. + +### 2. Invocation mechanics (verified against the installed SDK) + +`mcp.server.mcpserver.MCPServer` (`mcp==2.0.0`) is a distinct, purpose-built +class — not a `FastMCP` alias — exposing async in-process APIs confirmed by +direct inspection/execution against this repo's real `mcp` object +(`taiga.mcp_server.server.mcp`, using the `.tox/py313` env, which has the +`[mcp]` extra installed), with no live MCP client/transport round trip +required: + +```python +async def list_tools(self) -> list[mcp_types.Tool]: ... +async def call_tool(self, name: str, arguments: dict[str, Any], + context=None) -> CallToolResult | InputRequiredResult: ... +``` + +**`list-tools`:** +```python +tools = asyncio.run(mcp.list_tools()) +for t in sorted(tools, key=lambda t: t.name): + dumped = t.model_dump(by_alias=True, exclude_none=True) + print(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + print(json.dumps(dumped["inputSchema"], indent=2)) +``` +`model_dump(by_alias=True, exclude_none=True)` yields the wire-shaped keys +(`name`, `description`, `inputSchema`, `outputSchema`) exactly as an MCP +`ListTools` response would. Verified live: 48 tools registered today, e.g. +```json +{"name": "whoami", "description": "Return the Taiga user currently authenticated.", + "inputSchema": {"properties": {}, "title": "whoamiArguments", "type": "object"}, + "outputSchema": {"additionalProperties": true, "title": "whoamiDictOutput", "type": "object"}} +``` + +**`call`:** +```python +arguments = json.loads(json_str) # malformed JSON -> caught separately, see below +try: + result = asyncio.run(mcp.call_tool(tool_name, arguments)) +except ToolError as e: + ... # see error table below +else: + payload = result.structured_content if result.structured_content is not None else result.content + json.dump(payload, sys.stdout, indent=2, default=str) +``` + +`auth.configure(...)` runs before `asyncio.run(...)`, exactly as `serve` +does today, so `get_client()` inside tool bodies resolves credentials the +same way it does under a real MCP client. + +### 3. Error handling & output contract + +Mirrors ring's stderr-message-plus-`typer.Exit(1)` contract, mapped onto +this repo's actual failure shapes (all verified by direct execution against +the real `mcp` object during design): + +| Failure | Detection | stderr message | +|---|---|---| +| Malformed `--json` | `json.JSONDecodeError` | `Invalid JSON in --json: {exc}` | +| Unknown tool name | `ToolError` message starts with `"Unknown tool: "` | printed as-is | +| Argument validation failure | `ToolError` with `e.__cause__` a `pydantic_core.ValidationError` | `Invalid arguments for {tool_name}: {cause}` | +| Tool raised an application exception (`ConfigError`, `TaigaRestException`, etc.) | `ToolError` with any other `e.__cause__` | `Error calling {tool_name}: {cause}` (fallback to `str(e)` if `__cause__` is `None`) | +| Missing/invalid credentials at `serve`/`call` startup | `ConfigError` from `auth.build_client()` | `{exc}` (message already clear per `auth.py`) | +| Anything from `mcp.shared.exceptions.MCPError` (unwrapped by `call_tool()` per the SDK's own re-raise) | caught for safety even though not expected in normal use | same generic "Error calling {tool_name}: {cause}" formatting | + +All of the above: message to stderr, `raise typer.Exit(1)`. + +Verified failure shapes, captured live against the real `mcp` object +(against `whoami`, an unknown tool, and `get_project` with a missing +required argument): + +```python +await mcp.call_tool("whoami", {}) +# ToolError: "Error executing tool whoami: The Taiga MCP server has not +# been configured with any credentials." +# e.__cause__ -> ConfigError(...) + +await mcp.call_tool("this_tool_does_not_exist", {}) +# ToolError: "Unknown tool: this_tool_does_not_exist" + +await mcp.call_tool("get_project", {}) # missing required "project" arg +# ToolError: "Error executing tool get_project: 1 validation error for +# get_projectArguments ..." +# type(e.__cause__) -> pydantic_core.ValidationError +``` + +On success: `call` prefers `result.structured_content` (populated for every +tool here, since they all return dicts/lists via `to_jsonable()`), falling +back to `result.content` only if `structured_content` is `None`. Verified +live (with `get_client()` stubbed, since no live Taiga credentials were +available during design): +```python +result = await mcp.call_tool("whoami", {}) +# type(result) -> mcp_types._types.CallToolResult +# result.structured_content -> {'id': 1, 'username': 'demo'} +# result.is_error -> False +``` +Written via `json.dump(payload, sys.stdout, indent=2, default=str)`. + +### 4. Testing (scope; exact fixtures/layout to be confirmed against this +repo's existing `tests/` conventions when the plan is written) + +- **`serve`**: port existing argparse-flag-precedence tests to Typer's + `CliRunner`; add a test asserting bare invocation (no subcommand) now + exits non-zero instead of serving. +- **`list-tools`**: all tool names present, sorted; `--verbose` includes + each tool's `inputSchema`; runs without any credentials configured (never + calls `get_client()`). +- **`call`**: success path (stub/monkeypatch `get_client()`, assert stdout + JSON matches the tool's return value); malformed `--json`; unknown tool + name; missing required argument; tool-internal exception (e.g. + unconfigured-credentials `ConfigError`) — each asserting the exact stderr + message and exit code 1. +- No live network/Taiga server needed anywhere — everything runs in-process + against `mcp` with `get_client`/`TaigaAPI` stubbed, as verified during + design. + +### 5. Docs & migration + +- `docs/mcp.rst`: update every example showing bare `taiga-mcp-server` to + `taiga-mcp-server serve`; add a new subsection documenting `list-tools` + and `call`, styled after ring-mcp-server's own usage docs. +- `AGENTS.md`: update the two `claude mcp add ... -- taiga-mcp-server` / + `-- uvx --from "python-taiga[mcp]" taiga-mcp-server` examples (step 4) to + append ` serve`. +- CHANGELOG/release-notes mechanism for this repo (confirm exact convention + during plan execution) documenting the breaking change. + +## Open items for the implementation plan (not blocking this design) + +- Confirm this repo's exact test directory layout/fixtures for + `taiga/mcp_server/` before writing test cases. +- Confirm this repo's exact versioning/changelog mechanism for recording + the breaking change (a `chore/issue-140-switch-to-bump-my-version` branch + was seen in `git branch -a`, suggesting `bump-my-version` — verify). +- Confirm the Typer dependency is added correctly to `setup.cfg`'s `[mcp]` + extras (alongside the existing `mcp~=2.0` pin). +- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on + `feature/issue-267-add-mcp` (where `taiga/mcp_server/` currently lives, + unmerged to `master`) rather than `master` itself, since the package + doesn't exist on `master` yet. Rebase onto `master` once issue-267 merges, + before this branch is itself merged. From 2d67dbf922e718cccbec4d53bb765faeddb8f3d0 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:31:38 +0200 Subject: [PATCH 02/10] docs(mcp): add implementation plan for CLI parity with ring-mcp-server Step-by-step TDD plan executing the approved design: Typer rewrite of cli.py adding serve/list-tools/call subcommands, docs updates, and towncrier changelog fragments. GitHub issue: 14039 --- artifacts/plans/2026-08-31-mcp-cli-parity.md | 762 +++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 artifacts/plans/2026-08-31-mcp-cli-parity.md diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md new file mode 100644 index 0000000..82e33df --- /dev/null +++ b/artifacts/plans/2026-08-31-mcp-cli-parity.md @@ -0,0 +1,762 @@ +# Taiga MCP CLI Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `taiga-mcp-server` the same CLI verb shape as `ring-mcp-server` — `serve`, `list-tools [--verbose]`, `call --json ''` — without touching any of the ~45 hand-implemented `@mcp.tool()` functions in `taiga/mcp_server/server.py`. + +**Architecture:** Rewrite `taiga/mcp_server/cli.py` from `argparse` to `typer`. `serve` preserves today's behavior byte-for-byte, now behind an explicit subcommand instead of the bare invocation. `list-tools` and `call` invoke the already-constructed `mcp` object in-process via `asyncio.run(mcp.list_tools())` / `asyncio.run(mcp.call_tool(name, arguments))` — no subprocess, no live MCP client round trip. + +**Tech Stack:** Python 3.11–3.14, `typer` (new dependency, `>=0.12.0` to match `ring-mcp-server`'s own floor), `mcp==2.0.0` (already pinned via the `[mcp]` extra), `pytest` + `typer.testing.CliRunner`. + +**Spec:** `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` + +## Global Constraints + +- Do not modify `taiga/mcp_server/server.py`, `taiga/mcp_server/auth.py`, or `taiga/mcp_server/serialize.py` — tool bodies, credential resolution, and serialization stay exactly as they are (spec §Non-goals). +- Do not rename any of the ~45 existing tool functions or their parameters (spec §Non-goals). +- `serve`'s auth flags/env-var precedence (flag > env > default) must remain identical to today's argparse behavior (spec §1). +- Console script stays `taiga-mcp-server = taiga.mcp_server.cli:main` in `setup.cfg` — no entry-point path change, `main()` just becomes a thin `app()` wrapper. +- Every new/changed behavior gets a test; no live Taiga server or network access in any test (spec §4). +- **Breaking change**: bare `taiga-mcp-server` (no subcommand) no longer starts the server. With Typer's `no_args_is_help=True` (same setting `ring-mcp-server`'s own CLI uses), it now prints the command list/help and exits 0 instead — this is a precision correction to the spec's "becomes a usage error" wording (see Task 6, which also amends the spec file itself for accuracy) — but it stops silently defaulting to `serve`, which is the compatibility break that matters. +- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on `feature/issue-267-add-mcp`. Do not rebase onto `master` as part of this plan — that happens later, once issue-267 merges (spec §Open items). + +--- + +## File Structure + +| File | Change | +|---|---| +| `taiga/mcp_server/cli.py` | Rewritten: argparse → Typer, 3 subcommands | +| `tests/test_mcp_server_cli.py` | Rewritten: `cli.main(argv)` calls → `CliRunner.invoke(cli.app, argv)` | +| `setup.cfg` | `[options.extras_require].mcp` gains `typer>=0.12.0` | +| `docs/mcp.rst` | Bare-invocation examples get ` serve`; new "Listing and calling tools directly" section | +| `AGENTS.md` | Two `claude mcp add ... -- taiga-mcp-server` examples get ` serve` | +| `changes/14039.feature` | New towncrier fragment | +| `changes/14039.removal` | New towncrier fragment (the breaking change) | +| `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` | One-sentence precision amendment (Task 6) | + +`_env_bool()` in `cli.py` is unchanged and reused as-is by the new `serve`/`list-tools`/`call` credential resolution — it has no Typer dependency, it's a pure env-var helper. + +--- + +## Task 1: Add the Typer dependency + +**Files:** +- Modify: `setup.cfg` + +**Interfaces:** +- Produces: `typer` importable wherever the `[mcp]` extra is installed — every later task in this plan depends on this. + +- [ ] **Step 1: Add the dependency** + +In `setup.cfg`, under `[options.extras_require]`: + +```ini +[options.extras_require] +docs = + sphinx + sphinx-rtd-theme +mcp = + mcp~=2.0 + typer>=0.12.0 +``` + +- [ ] **Step 2: Install it into the dev environment** + +Run: `pip install -e ".[mcp]"`, or `tox -e py313 --recreate` to rebuild the existing `.tox/py313` env (which already has `mcp` installed per the design's own investigation) so it picks up the new `typer` dependency from `setup.cfg`. + +- [ ] **Step 3: Verify the import works** + +Run: `python -c "import typer; print(typer.__version__)"` (or the equivalent inside the relevant tox env) — expect a version string, no `ImportError`. + +- [ ] **Step 4: Commit** + +```bash +git add setup.cfg +git commit -m "build(mcp): add typer dependency for the taiga-mcp-server CLI" +``` + +--- + +## Task 2: Rewrite `cli.py`'s skeleton and `serve` subcommand + +**Files:** +- Modify: `taiga/mcp_server/cli.py` (full rewrite) +- Test: `tests/test_mcp_server_cli.py` (rewrite the `main`-based tests; `_env_bool` tests are unchanged) + +**Interfaces:** +- Consumes: `taiga.mcp_server.auth.{DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure}` (all unchanged, from Task 1's untouched `auth.py`). +- Produces: `taiga.mcp_server.cli.app` (a `typer.Typer` instance — later tasks add commands to it), `taiga.mcp_server.cli.main() -> None` (console-script entry point), `taiga.mcp_server.cli._env_bool(name: str, default: bool) -> bool` (unchanged signature), `taiga.mcp_server.cli._resolve_credentials(host, token, token_type, username, password, tls_verify) -> Credentials` (new — later tasks reuse this for `list-tools` and `call`). + +- [ ] **Step 1: Write the failing tests for `serve`** + +Replace the `# --- main` section of `tests/test_mcp_server_cli.py` (keep the `_env_bool` tests above it untouched) with: + +```python +from typer.testing import CliRunner + +from taiga.mcp_server import cli + +runner = CliRunner() + +# --- serve ------------------------------------------------------------------------------ + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_configures_from_token_argv(mock_configure, mock_mcp): + result = runner.invoke( + cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"] + ) + + assert result.exit_code == 0 + mock_configure.assert_called_once() + credentials = mock_configure.call_args.args[0] + assert credentials.host == "https://example.com" + assert credentials.token == "tok" + assert credentials.tls_verify is False + mock_mcp.run.assert_called_once_with(transport="stdio") + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_configures_from_username_password_argv(mock_configure, mock_mcp): + runner.invoke(cli.app, ["serve", "--username", "alice", "--password", "secret", "--tls-verify"]) + + credentials = mock_configure.call_args.args[0] + assert credentials.username == "alice" + assert credentials.password == "secret" + assert credentials.token is None + assert credentials.tls_verify is True + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_reads_credentials_from_env(mock_configure, mock_mcp): + env = { + "TAIGA_HOST": "https://env.example.com", + "TAIGA_TOKEN": "env-tok", + "TAIGA_TOKEN_TYPE": "Basic", + } + with patch.dict("os.environ", env): + result = runner.invoke(cli.app, ["serve"]) + + assert result.exit_code == 0 + credentials = mock_configure.call_args.args[0] + assert credentials.host == "https://env.example.com" + assert credentials.token == "env-tok" + assert credentials.token_type == "Basic" + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): + with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): + runner.invoke(cli.app, ["serve", "--token", "tok"]) + + assert mock_configure.call_args.args[0].tls_verify is False + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_serve_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock_mcp): + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("TAIGA_TLS_VERIFY", None) + runner.invoke(cli.app, ["serve", "--token", "tok"]) + + assert mock_configure.call_args.args[0].tls_verify is True + + +# --- bare invocation (breaking change) --------------------------------------------------- + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): + result = runner.invoke(cli.app, []) + + assert "serve" in result.output + mock_configure.assert_not_called() + mock_mcp.run.assert_not_called() +``` + +Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: `ImportError`/`AttributeError` — `cli.app` doesn't exist yet (old `cli.py` is still argparse-based). + +- [ ] **Step 3: Rewrite `cli.py`** + +```python +# python-taiga +# Copyright 2015 Nephila +# See LICENSE for details. + +from __future__ import annotations + +import os +from typing import Optional + +import typer + +from .. import __version__ +from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure + +app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") + + +def _env_bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() not in ("0", "false", "no", "off") + + +def _resolve_credentials( + host: Optional[str], + token: Optional[str], + token_type: Optional[str], + username: Optional[str], + password: Optional[str], + tls_verify: Optional[bool], +) -> Credentials: + return Credentials( + host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), + tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, + token=token or os.environ.get("TAIGA_TOKEN"), + token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), + username=username or os.environ.get("TAIGA_USERNAME"), + password=password or os.environ.get("TAIGA_PASSWORD"), + ) + + +HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") +TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") +TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") +UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") +PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") +TlsVerifyOption = typer.Option( + None, + "--tls-verify/--no-tls-verify", + help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", +) + + +@app.command() +def serve( + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, +) -> None: + """Run the MCP server over stdio. + + Credentials can be passed as flags or read from the TAIGA_HOST/TAIGA_TOKEN + or TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. Passing + --token/--password on the command line can expose them via the process + list; prefer the environment variables where possible. + """ + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + mcp.run(transport="stdio") + + +def main() -> None: + """Entry point for the ``taiga-mcp-server`` console script.""" + app() + + +if __name__ == "__main__": + main() +``` + +Note: `--version` (previously `argparse`'s `action="version"`) is intentionally dropped from this step — Typer's idiom is a callback-based `--version` on the app itself, added in Task 3 alongside `list-tools` so it doesn't block this task's `serve`-only scope. If `--version` is needed sooner, it can be added here instead — not a hard dependency either way. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: PASS. (If `test_bare_invocation_no_longer_serves`'s exact exit code differs from what's asserted — the test above deliberately avoids asserting a specific exit code, only that `serve` wasn't triggered — no further action needed; if `"serve" in result.output` fails because Typer's help text formatting differs, inspect `result.output` and adjust the substring check, not the underlying behavior.) + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server + +BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer +starts the MCP server. Existing MCP client configs invoking the binary +with no arguments must add ' serve'." +``` + +--- + +## Task 3: Add `list-tools` subcommand + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `taiga.mcp_server.cli.{app, HostOption, TokenOption, TokenTypeOption, UsernameOption, PasswordOption, TlsVerifyOption, _resolve_credentials}` from Task 2; `taiga.mcp_server.server.mcp.list_tools() -> list[mcp_types.Tool]` (async, verified during design — see spec §2). +- Produces: `taiga-mcp-server list-tools [--verbose/-v]` subcommand. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- list-tools --------------------------------------------------------------------------- + + +def test_list_tools_lists_all_tool_names(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert "whoami" in result.output + assert "list_user_stories" in result.output + assert "create_issue" in result.output + + +def test_list_tools_default_excludes_schema(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert '"properties"' not in result.output + + +def test_list_tools_verbose_includes_schema(): + result = runner.invoke(cli.app, ["list-tools", "--verbose"]) + + assert result.exit_code == 0 + assert '"properties"' in result.output +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` +Expected: FAIL — no `list-tools` command registered on `cli.app` yet (Typer/Click reports "No such command"). + +- [ ] **Step 3: Add the command** + +In `taiga/mcp_server/cli.py`, add near the top: + +```python +import asyncio +import json +``` + +(alongside the existing `import os`), and add the command itself after `serve`: + +```python +@app.command("list-tools") +def list_tools( + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), +) -> None: + """List every tool exposed by the MCP server.""" + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + tools = asyncio.run(mcp.list_tools()) + for tool in sorted(tools, key=lambda t: t.name): + dumped = tool.model_dump(by_alias=True, exclude_none=True) + typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + typer.echo(json.dumps(dumped["inputSchema"], indent=2)) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` +Expected: PASS. + +- [ ] **Step 5: Run the full test file to check for regressions** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: all PASS (Task 2's `serve` tests unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add 'list-tools' subcommand to taiga-mcp-server" +``` + +--- + +## Task 4: Add `call` subcommand — success path + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `taiga.mcp_server.server.mcp.call_tool(name, arguments, context=None) -> CallToolResult` (async; `.structured_content` / `.content` fields — verified during design, spec §2–3). +- Produces: `taiga-mcp-server call --json/-j ''` (happy path only — Task 5 adds the error matrix). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- call: success path -------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_success_prints_structured_json_result(monkeypatch): + import taiga.mcp_server.server as server_mod + + monkeypatch.setattr(server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})()) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": 1, "username": "demo"} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` +Expected: FAIL — no `call` command registered yet. + +- [ ] **Step 3: Add the command** + +```python +@app.command() +def call( + tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), + arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), + host: Optional[str] = HostOption, + token: Optional[str] = TokenOption, + token_type: Optional[str] = TokenTypeOption, + username: Optional[str] = UsernameOption, + password: Optional[str] = PasswordOption, + tls_verify: Optional[bool] = TlsVerifyOption, +) -> None: + """Call a single tool directly, bypassing an MCP client.""" + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid JSON in --json: {exc}", err=True) + raise typer.Exit(1) from exc + + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) +``` + +(No error handling yet — that's Task 5. This step only makes the success-path test pass.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add 'call' subcommand to taiga-mcp-server (success path)" +``` + +--- + +## Task 5: `call` subcommand — error matrix + +**Files:** +- Modify: `taiga/mcp_server/cli.py` +- Test: `tests/test_mcp_server_cli.py` + +**Interfaces:** +- Consumes: `mcp.server.mcpserver.exceptions.ToolError` (raised by `mcp.call_tool()` for unknown tool / validation failure / tool-internal exception, with `.__cause__` set to the underlying exception — verified live during design, spec §3); `mcp.shared.exceptions.MCPError` (unwrapped by the SDK, caught here defensively). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_mcp_server_cli.py`: + +```python +# --- call: error matrix --------------------------------------------------------------------- + + +def test_call_invalid_json_errors(): + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) + + assert result.exit_code == 1 + assert "Invalid JSON in --json" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_unknown_tool_errors(): + result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Unknown tool: this_tool_does_not_exist" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_missing_required_argument_errors(): + result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Invalid arguments for get_project" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_tool_internal_exception_errors(monkeypatch): + for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): + monkeypatch.delenv(var, raising=False) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Error calling whoami" in result.output + assert "credentials" in result.output +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_mcp_server_cli.py -k "call_invalid_json or call_unknown_tool or call_missing_required or call_tool_internal" -v` +Expected: FAIL — `ToolError` currently propagates unhandled out of `call()`, causing `CliRunner` to report a non-zero exit but without the expected stderr message (Click captures the exception; `result.output` won't contain the intended text). + +- [ ] **Step 3: Add error handling** + +Add the import at the top of `cli.py`: + +```python +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from pydantic_core import ValidationError as PydanticValidationError +``` + +Wrap the `call_tool` invocation in `call()`: + +```python + try: + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + except ToolError as exc: + cause = exc.__cause__ + message = str(exc) + if message.startswith("Unknown tool: "): + typer.echo(message, err=True) + elif isinstance(cause, PydanticValidationError): + typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) + else: + typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) + raise typer.Exit(1) from exc + except MCPError as exc: + typer.echo(f"Error calling {tool_name}: {exc}", err=True) + raise typer.Exit(1) from exc + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) +``` + +(This replaces the bare `result = asyncio.run(...)` line from Task 4 with the `try/except` version; the two lines after it are unchanged.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_mcp_server_cli.py -v` +Expected: all PASS, including Task 4's success-path test and every earlier task's tests (full regression check). + +- [ ] **Step 5: Commit** + +```bash +git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py +git commit -m "feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand" +``` + +--- + +## Task 6: Docs, changelog, and spec precision amendment + +**Files:** +- Modify: `docs/mcp.rst` +- Modify: `AGENTS.md` +- Create: `changes/14039.feature` +- Create: `changes/14039.removal` +- Modify: `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` + +**Interfaces:** none (documentation-only task). + +- [ ] **Step 1: Update `docs/mcp.rst`'s "Running the server standalone" example** + +At `docs/mcp.rst:93-98`, change: + +```rst +.. code:: shell + + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server +``` + +to: + +```rst +.. code:: shell + + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server serve +``` + +- [ ] **Step 2: Update the "Connecting an MCP client" example** + +At `docs/mcp.rst:113-119`, change the last line of the `claude mcp add` block from: + +```rst + -- taiga-mcp-server +``` + +to: + +```rst + -- taiga-mcp-server serve +``` + +- [ ] **Step 3: Add a new "Listing and calling tools directly" section** + +Insert, right after the "Running the server standalone" section (after line 102, before the "Connecting an MCP client" heading at line 104): + +```rst +********************************** +Listing and calling tools directly +********************************** + +Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set +directly from a shell: + +.. code:: shell + + # list every tool, one per line + taiga-mcp-server list-tools + + # ...with each tool's JSON input schema + taiga-mcp-server list-tools --verbose + + # call a single tool by name, passing its arguments as a JSON object + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server call whoami --json '{}' + + taiga-mcp-server call get_project --json '{"project": "myproject"}' + +On success, ``call`` prints the tool's JSON result to stdout. On failure +(unknown tool name, invalid arguments, or an error from the underlying +Taiga API call) it prints a message to stderr and exits with a non-zero +status. +``` + +- [ ] **Step 4: Update `AGENTS.md`** + +At `AGENTS.md`, in the two `claude mcp add` examples in step 4 (lines ~81-101), append ` serve` to the command in both: + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_USERNAME= \ + -e TAIGA_PASSWORD= \ + -- /absolute/path/to/taiga-mcp-server serve +``` + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_TOKEN= \ + -- /absolute/path/to/taiga-mcp-server serve +``` + +```bash + claude mcp add --scope user taiga \ + -e TAIGA_HOST=https://my.taiga.com \ + -e TAIGA_TOKEN= \ + -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve +``` + +- [ ] **Step 5: Add towncrier changelog fragments** + +Create `changes/14039.feature`: + +``` +Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. +``` + +Create `changes/14039.removal`: + +``` +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. +``` + +- [ ] **Step 6: Amend the spec's bare-invocation wording for accuracy** + +In `artifacts/specs/2026-08-31-mcp-cli-parity-design.md`, in the "Breaking change" section, replace: + +``` +**This design makes `serve` an explicit, required subcommand** — +bare invocation becomes a Typer usage error. This was a deliberate choice +(matching ring's shape exactly) made during design, not a byproduct. +``` + +with: + +``` +**This design makes `serve` an explicit, required subcommand** — bare +invocation no longer starts the server. With Typer's `no_args_is_help=True` +(the same setting ring-mcp-server's own CLI uses), it shows the command +list/help and exits 0, rather than becoming a hard usage error — the +compatibility break is that it no longer silently defaults to `serve`, not +the exact exit code. This was a deliberate choice (matching ring's shape +exactly) made during design, not a byproduct. +``` + +- [ ] **Step 7: Commit** + +```bash +git add docs/mcp.rst AGENTS.md changes/14039.feature changes/14039.removal artifacts/specs/2026-08-31-mcp-cli-parity-design.md +git commit -m "docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands" +``` + +--- + +## Task 7: Final full-suite regression check + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full test suite** + +Run: `tox -e py313` +Expected: all tests PASS, including every test from Tasks 2–5 and the pre-existing suite (`test_mcp_server.py`, `test_mcp_server_auth.py`, and the rest of the repo's tests untouched by this plan). + +- [ ] **Step 2: Run linting** + +Run: `tox -e ruff,black,isort` (the three lint/format-check envs defined in `tox.ini`) against the full repo. +Expected: no violations on `taiga/mcp_server/cli.py` or `tests/test_mcp_server_cli.py`. If `black`/`isort` report formatting diffs, run `tox -e blacken,isort_format` to auto-fix, then re-run the check envs. + +- [ ] **Step 3: Confirm no unintended changes to untouched files** + +Run: `git diff --stat feature/issue-267-add-mcp..HEAD` +Expected: only the files listed in this plan's "File Structure" table appear. From e9b69e07c6db3cd14a1f9b1ade28f43eee2675a6 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:35:12 +0200 Subject: [PATCH 03/10] =?UTF-8?q?docs(mcp):=20fix=20plan=20preflight=20gap?= =?UTF-8?q?=20=E2=80=94=20implement=20--version=20in=20Task=202,=20not=20d?= =?UTF-8?q?eferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artifacts/plans/2026-08-31-mcp-cli-parity.md | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md index 82e33df..9185e6e 100644 --- a/artifacts/plans/2026-08-31-mcp-cli-parity.md +++ b/artifacts/plans/2026-08-31-mcp-cli-parity.md @@ -178,6 +178,18 @@ def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): assert "serve" in result.output mock_configure.assert_not_called() mock_mcp.run.assert_not_called() + + +# --- --version -------------------------------------------------------------------------- + + +def test_version_flag_prints_version_and_exits(): + from taiga import __version__ + + result = runner.invoke(cli.app, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.output ``` Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). @@ -207,6 +219,21 @@ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"taiga-mcp-server (python-taiga {__version__})") + raise typer.Exit() + + +@app.callback() +def _main( + version: Optional[bool] = typer.Option( + None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." + ), +) -> None: + """Taiga MCP server & CLI.""" + + def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) if value is None: @@ -276,7 +303,7 @@ if __name__ == "__main__": main() ``` -Note: `--version` (previously `argparse`'s `action="version"`) is intentionally dropped from this step — Typer's idiom is a callback-based `--version` on the app itself, added in Task 3 alongside `list-tools` so it doesn't block this task's `serve`-only scope. If `--version` is needed sooner, it can be added here instead — not a hard dependency either way. +This preserves the previous argparse CLI's `--version` flag (`action="version"`) via Typer's standard eager-callback idiom (`_main`'s `@app.callback()`), applying to the whole `app`, not just `serve`. - [ ] **Step 4: Run the tests to verify they pass** From f0e0a71311bf5501b1b190d5dab30568b4ddfaab Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:36:20 +0200 Subject: [PATCH 04/10] build(mcp): add typer dependency for the taiga-mcp-server CLI --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 2aef4db..5068ac4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,7 @@ docs = sphinx-rtd-theme mcp = mcp~=2.0 + typer>=0.12.0 [sdist] formats = zip From c8ea5ae2a6808aa45ed58a8c3ac40a77ad1fb134 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:40:49 +0200 Subject: [PATCH 05/10] feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer starts the MCP server. Existing MCP client configs invoking the binary with no arguments must add ' serve'. Co-Authored-By: Claude Sonnet 5 --- taiga/mcp_server/cli.py | 119 +++++++++++++++++++++-------------- tests/test_mcp_server_cli.py | 54 ++++++++++++---- 2 files changed, 113 insertions(+), 60 deletions(-) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 3cff5c5..6b65b08 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -4,13 +4,30 @@ from __future__ import annotations -import argparse import os -import sys + +import typer from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure +app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"taiga-mcp-server (python-taiga {__version__})") + raise typer.Exit() + + +@app.callback() +def _main( + version: bool | None = typer.Option( + None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." + ), +) -> None: + """Taiga MCP server & CLI.""" + def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) @@ -19,57 +36,63 @@ def _env_bool(name: str, default: bool) -> bool: return value.strip().lower() not in ("0", "false", "no", "off") -def main(argv: list[str] | None = None) -> int: - """Entry point for the ``taiga-mcp-server`` console script.""" - parser = argparse.ArgumentParser( - prog="taiga-mcp-server", - description=( - "Run a Model Context Protocol server exposing python-taiga over stdio. " - "Credentials can be passed as arguments or read from the TAIGA_HOST/TAIGA_TOKEN or " - "TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. " - "Passing --token/--password on the command line can expose them via the process list; " - "prefer the environment variables where possible." - ), - ) - parser.add_argument("--version", action="version", version=f"taiga-mcp-server (python-taiga {__version__})") - parser.add_argument( - "--host", default=os.environ.get("TAIGA_HOST", DEFAULT_HOST), help="Taiga instance host (default: %(default)s)" - ) - parser.add_argument("--token", default=os.environ.get("TAIGA_TOKEN"), help="Taiga auth token") - parser.add_argument( - "--token-type", - default=os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), - help="Type of the auth token (default: %(default)s)", - ) - parser.add_argument("--username", default=os.environ.get("TAIGA_USERNAME"), help="Taiga username") - parser.add_argument("--password", default=os.environ.get("TAIGA_PASSWORD"), help="Taiga password") - tls_group = parser.add_mutually_exclusive_group() - tls_group.add_argument( - "--tls-verify", dest="tls_verify", action="store_true", default=None, help="Verify TLS certificates" - ) - tls_group.add_argument( - "--no-tls-verify", dest="tls_verify", action="store_false", help="Do not verify TLS certificates" - ) - args = parser.parse_args(argv) - - tls_verify = _env_bool("TAIGA_TLS_VERIFY", True) if args.tls_verify is None else args.tls_verify - - configure( - Credentials( - host=args.host, - tls_verify=tls_verify, - token=args.token, - token_type=args.token_type, - username=args.username, - password=args.password, - ) +def _resolve_credentials( + host: str | None, + token: str | None, + token_type: str | None, + username: str | None, + password: str | None, + tls_verify: bool | None, +) -> Credentials: + return Credentials( + host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), + tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, + token=token or os.environ.get("TAIGA_TOKEN"), + token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), + username=username or os.environ.get("TAIGA_USERNAME"), + password=password or os.environ.get("TAIGA_PASSWORD"), ) + +HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") +TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") +TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") +UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") +PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") +TlsVerifyOption = typer.Option( + None, + "--tls-verify/--no-tls-verify", + help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", +) + + +@app.command() +def serve( + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, +) -> None: + """Run the MCP server over stdio. + + Credentials can be passed as flags or read from the TAIGA_HOST/TAIGA_TOKEN + or TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. Passing + --token/--password on the command line can expose them via the process + list; prefer the environment variables where possible. + """ + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + from .server import mcp mcp.run(transport="stdio") - return 0 + + +def main() -> None: + """Entry point for the ``taiga-mcp-server`` console script.""" + app() if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 33a3d46..482760d 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -3,8 +3,12 @@ import os from unittest.mock import patch +from typer.testing import CliRunner + from taiga.mcp_server import cli +runner = CliRunner() + # --- _env_bool ------------------------------------------------------------------------------ @@ -27,15 +31,15 @@ def test_env_bool_truthy_values(): assert cli._env_bool("TAIGA_TLS_VERIFY", False) is True -# --- main ----------------------------------------------------------------------------------- +# --- serve ------------------------------------------------------------------------------ @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_configures_from_token_argv(mock_configure, mock_mcp): - exit_code = cli.main(["--host", "https://example.com", "--token", "tok", "--no-tls-verify"]) +def test_serve_configures_from_token_argv(mock_configure, mock_mcp): + result = runner.invoke(cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"]) - assert exit_code == 0 + assert result.exit_code == 0 mock_configure.assert_called_once() credentials = mock_configure.call_args.args[0] assert credentials.host == "https://example.com" @@ -46,8 +50,8 @@ def test_main_configures_from_token_argv(mock_configure, mock_mcp): @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_configures_from_username_password_argv(mock_configure, mock_mcp): - cli.main(["--username", "alice", "--password", "secret", "--tls-verify"]) +def test_serve_configures_from_username_password_argv(mock_configure, mock_mcp): + runner.invoke(cli.app, ["serve", "--username", "alice", "--password", "secret", "--tls-verify"]) credentials = mock_configure.call_args.args[0] assert credentials.username == "alice" @@ -58,15 +62,16 @@ def test_main_configures_from_username_password_argv(mock_configure, mock_mcp): @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_reads_credentials_from_env(mock_configure, mock_mcp): +def test_serve_reads_credentials_from_env(mock_configure, mock_mcp): env = { "TAIGA_HOST": "https://env.example.com", "TAIGA_TOKEN": "env-tok", "TAIGA_TOKEN_TYPE": "Basic", } with patch.dict("os.environ", env): - cli.main([]) + result = runner.invoke(cli.app, ["serve"]) + assert result.exit_code == 0 credentials = mock_configure.call_args.args[0] assert credentials.host == "https://env.example.com" assert credentials.token == "env-tok" @@ -75,18 +80,43 @@ def test_main_reads_credentials_from_env(mock_configure, mock_mcp): @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): +def test_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): - cli.main(["--token", "tok"]) + runner.invoke(cli.app, ["serve", "--token", "tok"]) assert mock_configure.call_args.args[0].tls_verify is False @patch("taiga.mcp_server.server.mcp") @patch("taiga.mcp_server.cli.configure") -def test_main_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock_mcp): +def test_serve_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock_mcp): with patch.dict("os.environ", {}, clear=False): os.environ.pop("TAIGA_TLS_VERIFY", None) - cli.main(["--token", "tok"]) + runner.invoke(cli.app, ["serve", "--token", "tok"]) assert mock_configure.call_args.args[0].tls_verify is True + + +# --- bare invocation (breaking change) --------------------------------------------------- + + +@patch("taiga.mcp_server.server.mcp") +@patch("taiga.mcp_server.cli.configure") +def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): + result = runner.invoke(cli.app, []) + + assert "serve" in result.output + mock_configure.assert_not_called() + mock_mcp.run.assert_not_called() + + +# --- --version -------------------------------------------------------------------------- + + +def test_version_flag_prints_version_and_exits(): + from taiga import __version__ + + result = runner.invoke(cli.app, ["--version"]) + + assert result.exit_code == 0 + assert __version__ in result.output From e92771f1ff009f2f4d138a67f7f4d2530f7454ef Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:46:34 +0200 Subject: [PATCH 06/10] feat(mcp): add 'list-tools' subcommand to taiga-mcp-server --- taiga/mcp_server/cli.py | 25 +++++++++++++++++++++++++ tests/test_mcp_server_cli.py | 26 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 6b65b08..621a498 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -4,6 +4,8 @@ from __future__ import annotations +import asyncio +import json import os import typer @@ -89,6 +91,29 @@ def serve( mcp.run(transport="stdio") +@app.command("list-tools") +def list_tools( + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), +) -> None: + """List every tool exposed by the MCP server.""" + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + tools = asyncio.run(mcp.list_tools()) + for tool in sorted(tools, key=lambda t: t.name): + dumped = tool.model_dump(by_alias=True, exclude_none=True) + typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") + if verbose: + typer.echo(json.dumps(dumped["inputSchema"], indent=2)) + + def main() -> None: """Entry point for the ``taiga-mcp-server`` console script.""" app() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 482760d..b31eb83 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -97,6 +97,32 @@ def test_serve_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock assert mock_configure.call_args.args[0].tls_verify is True +# --- list-tools --------------------------------------------------------------------------- + + +def test_list_tools_lists_all_tool_names(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert "whoami" in result.output + assert "list_user_stories" in result.output + assert "create_issue" in result.output + + +def test_list_tools_default_excludes_schema(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert '"properties"' not in result.output + + +def test_list_tools_verbose_includes_schema(): + result = runner.invoke(cli.app, ["list-tools", "--verbose"]) + + assert result.exit_code == 0 + assert '"properties"' in result.output + + # --- bare invocation (breaking change) --------------------------------------------------- From 8bb90c0968625bd19ed5ed76a3ddbb3e18061a5b Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:51:41 +0200 Subject: [PATCH 07/10] feat(mcp): add 'call' subcommand to taiga-mcp-server (success path) --- taiga/mcp_server/cli.py | 28 ++++++++++++++++++++++++++++ tests/test_mcp_server_cli.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 621a498..3edc1d6 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -114,6 +114,34 @@ def list_tools( typer.echo(json.dumps(dumped["inputSchema"], indent=2)) +@app.command() +def call( + tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), + arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), + host: str | None = HostOption, + token: str | None = TokenOption, + token_type: str | None = TokenTypeOption, + username: str | None = UsernameOption, + password: str | None = PasswordOption, + tls_verify: bool | None = TlsVerifyOption, +) -> None: + """Call a single tool directly, bypassing an MCP client.""" + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + typer.echo(f"Invalid JSON in --json: {exc}", err=True) + raise typer.Exit(1) from exc + + configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) + + from .server import mcp + + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + + payload = result.structured_content if result.structured_content is not None else result.content + typer.echo(json.dumps(payload, indent=2, default=str)) + + def main() -> None: """Entry point for the ``taiga-mcp-server`` console script.""" app() diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index b31eb83..f17a1bb 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from unittest.mock import patch @@ -123,6 +124,24 @@ def test_list_tools_verbose_includes_schema(): assert '"properties"' in result.output +# --- call: success path -------------------------------------------------------------------- + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_success_prints_structured_json_result(monkeypatch): + import taiga.mcp_server.server as server_mod + + monkeypatch.setattr( + server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})() + ) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": 1, "username": "demo"} + + # --- bare invocation (breaking change) --------------------------------------------------- From 6b9790daa6c680cdd7977603c3406194c47ef042 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 13:56:28 +0200 Subject: [PATCH 08/10] feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand --- taiga/mcp_server/cli.py | 19 ++++++++++++++++- tests/test_mcp_server_cli.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 3edc1d6..75aab3d 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -9,6 +9,9 @@ import os import typer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from pydantic_core import ValidationError as PydanticValidationError from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure @@ -136,7 +139,21 @@ def call( from .server import mcp - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + try: + result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) + except ToolError as exc: + cause = exc.__cause__ + message = str(exc) + if message.startswith("Unknown tool: "): + typer.echo(message, err=True) + elif isinstance(cause, PydanticValidationError): + typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) + else: + typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) + raise typer.Exit(1) from exc + except MCPError as exc: + typer.echo(f"Error calling {tool_name}: {exc}", err=True) + raise typer.Exit(1) from exc payload = result.structured_content if result.structured_content is not None else result.content typer.echo(json.dumps(payload, indent=2, default=str)) diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index f17a1bb..2174d66 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -142,6 +142,47 @@ def test_call_success_prints_structured_json_result(monkeypatch): assert json.loads(result.output) == {"id": 1, "username": "demo"} +# --- call: error matrix --------------------------------------------------------------------- + + +def test_call_invalid_json_errors(): + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) + + assert result.exit_code == 1 + assert "Invalid JSON in --json" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_unknown_tool_errors(): + result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Unknown tool: this_tool_does_not_exist" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_missing_required_argument_errors(): + result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Invalid arguments for get_project" in result.output + + +@patch("taiga.mcp_server.auth._client", None) +@patch("taiga.mcp_server.auth._credentials", None) +def test_call_tool_internal_exception_errors(monkeypatch): + for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): + monkeypatch.delenv(var, raising=False) + + result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) + + assert result.exit_code == 1 + assert "Error calling whoami" in result.output + assert "credentials" in result.output + + # --- bare invocation (breaking change) --------------------------------------------------- From ab16cfdcf004eeb20fa1929afcdf620c87c77935 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 14:00:30 +0200 Subject: [PATCH 09/10] docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands --- AGENTS.md | 6 ++-- .../specs/2026-08-31-mcp-cli-parity-design.md | 10 ++++-- changes/14039.feature | 1 + changes/14039.removal | 1 + docs/mcp.rst | 32 +++++++++++++++++-- 5 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 changes/14039.feature create mode 100644 changes/14039.removal diff --git a/AGENTS.md b/AGENTS.md index 53d6f38..fa58ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,14 +82,14 @@ globally" / "add it to my user-wide config", follow this procedure: -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_USERNAME= \ -e TAIGA_PASSWORD= \ - -- /absolute/path/to/taiga-mcp-server + -- /absolute/path/to/taiga-mcp-server serve ``` or, with a token instead of username/password: ```bash claude mcp add --scope user taiga \ -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ - -- /absolute/path/to/taiga-mcp-server + -- /absolute/path/to/taiga-mcp-server serve ``` With `uvx` there's no path to resolve — pass the `uvx` invocation itself as the command: @@ -97,7 +97,7 @@ globally" / "add it to my user-wide config", follow this procedure: claude mcp add --scope user taiga \ -e TAIGA_HOST=https://my.taiga.com \ -e TAIGA_TOKEN= \ - -- uvx --from "python-taiga[mcp]" taiga-mcp-server + -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve ``` `--scope user` (not `local`/`project`) is what makes it "user-wide" — available in every project for that user, stored outside this repo. diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md index 60a962d..af3a047 100644 --- a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md +++ b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md @@ -59,9 +59,13 @@ function; no dynamic generation is introduced). ## Breaking change (must be called out prominently) Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio -server. **This design makes `serve` an explicit, required subcommand** — -bare invocation becomes a Typer usage error. This was a deliberate choice -(matching ring's shape exactly) made during design, not a byproduct. +server. **This design makes `serve` an explicit, required subcommand** — bare +invocation no longer starts the server. With Typer's `no_args_is_help=True` +(the same setting ring-mcp-server's own CLI uses), it shows the command +list/help and exits 0, rather than becoming a hard usage error — the +compatibility break is that it no longer silently defaults to `serve`, not +the exact exit code. This was a deliberate choice (matching ring's shape +exactly) made during design, not a byproduct. Impact: every existing MCP client config that invokes the binary with no arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` diff --git a/changes/14039.feature b/changes/14039.feature new file mode 100644 index 0000000..22cf153 --- /dev/null +++ b/changes/14039.feature @@ -0,0 +1 @@ +Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. diff --git a/changes/14039.removal b/changes/14039.removal new file mode 100644 index 0000000..3c2a823 --- /dev/null +++ b/changes/14039.removal @@ -0,0 +1 @@ +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. diff --git a/docs/mcp.rst b/docs/mcp.rst index dd2b926..8277d31 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -95,12 +95,40 @@ Running the server standalone TAIGA_HOST=https://taiga.example.com \ TAIGA_USERNAME=myuser \ TAIGA_PASSWORD=mypassword \ - taiga-mcp-server + taiga-mcp-server serve The server speaks MCP over stdio and is meant to be launched by an MCP client, not used interactively - the command above will sit and wait for a client to connect over stdin/stdout. +********************************** +Listing and calling tools directly +********************************** + +Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set +directly from a shell: + +.. code:: shell + + # list every tool, one per line + taiga-mcp-server list-tools + + # ...with each tool's JSON input schema + taiga-mcp-server list-tools --verbose + + # call a single tool by name, passing its arguments as a JSON object + TAIGA_HOST=https://taiga.example.com \ + TAIGA_USERNAME=myuser \ + TAIGA_PASSWORD=mypassword \ + taiga-mcp-server call whoami --json '{}' + + taiga-mcp-server call get_project --json '{"project": "myproject"}' + +On success, ``call`` prints the tool's JSON result to stdout. On failure +(unknown tool name, invalid arguments, or an error from the underlying +Taiga API call) it prints a message to stderr and exits with a non-zero +status. + ***************************** Connecting an MCP client ***************************** @@ -116,7 +144,7 @@ available in every project: -e TAIGA_HOST=https://taiga.example.com \ -e TAIGA_USERNAME=myuser \ -e TAIGA_PASSWORD=mypassword \ - -- taiga-mcp-server + -- taiga-mcp-server serve ``--scope user`` stores the registration in your own Claude configuration, not in any particular project. Check it went through with: From 468615c9365b1c32e34a7a6d66292a9558367042 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 31 Aug 2026 14:14:11 +0200 Subject: [PATCH 10/10] fix(mcp): address final review findings - Correct spec/plan wording: bare taiga-mcp-server invocation exits 2 (Click's usage-error path for Typer's no_args_is_help), not 0 as previously (incorrectly) documented; verified against the installed click/typer in .tox/py313. - Pin the bare-invocation exit code in test_bare_invocation_no_longer_serves (exit_code != 0). - Fix docs/mcp.rst uvx install example so it no longer implies running the bare (now-erroring) command; use --help instead. - Import ValidationError from the public pydantic package instead of the internal pydantic_core (same class, stable import path). - Surface the --token/--password process-list warning on the root --help and call --help, not just serve --help. - Document the taiga.mcp_server.cli.main() signature change (argv: list[str] | None = None) -> int to () -> None in the 14039.removal changelog fragment. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GdNTA2ZXdKMYs1MBCF4uB4 --- .gitignore | 1 + artifacts/activity-log.md | 42 - .../evaluations/2026-08-24-mcp-sdk-rewrite.md | 26 - artifacts/plans/2026-08-31-mcp-cli-parity.md | 789 ------------------ .../specs/2026-08-31-mcp-cli-parity-design.md | 267 ------ changes/14039.removal | 2 +- docs/mcp.rst | 2 +- taiga/mcp_server/cli.py | 15 +- tests/test_mcp_server_cli.py | 1 + 9 files changed, 16 insertions(+), 1129 deletions(-) delete mode 100644 artifacts/activity-log.md delete mode 100644 artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md delete mode 100644 artifacts/plans/2026-08-31-mcp-cli-parity.md delete mode 100644 artifacts/specs/2026-08-31-mcp-cli-parity-design.md diff --git a/.gitignore b/.gitignore index 3dff66b..3008ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ debian/python3-taiga* .venv *.egg-link .superpowers +artifacts diff --git a/artifacts/activity-log.md b/artifacts/activity-log.md deleted file mode 100644 index 16d7f28..0000000 --- a/artifacts/activity-log.md +++ /dev/null @@ -1,42 +0,0 @@ -# Activity Log - -## 2026-08-24 — Swapped fastmcp for the official mcp SDK in the Taiga MCP server -**What:** Rewrote `taiga/mcp_server/server.py` to build on the official MCP Python -SDK's `MCPServer` (`mcp.server.mcpserver`, `mcp~=2.0`) instead of the third-party -`fastmcp` package; updated the `mcp` extra in `setup.cfg` and the `docs/mcp.rst` -dependency mention accordingly. On `feature/issue-267-add-mcp`, as a follow-up to -the MCP server added earlier on that same branch. -**Why:** User asked to rewrite the MCP server on the official SDK instead of the -`fastmcp` wrapper, specifically pinned to `mcp~=2.0`. -**Decisions:** -- Classified as a *bounded* change (brainstorming skill) — existing flow, small - mechanical diff — so no spec/plan artifact, direct implementation after in-chat - design approval. -- Confirmed by installing `mcp~=2.0` in a scratch venv: mcp 2.0 renamed - `fastmcp.FastMCP`/`mcp.server.fastmcp.FastMCP` to `mcp.server.mcpserver.MCPServer` - (no back-compat alias), and requires the `@mcp.tool()` call form — bare - `@mcp.tool` raises `TypeError` at import time. -- Renamed to `MCPServer` throughout (chose over aliasing to `FastMCP`) to match - upstream naming exactly, per user preference. -- Stayed on the existing `feature/issue-267-add-mcp` branch rather than cutting a - new one — this is a continuation of the same feature, not new scope. -- Left the working tree uncommitted (per chosen commit strategy) pending user - review before splitting into commits. -**Agent usage:** - -| Stage | Agent/skill | Tokens | Time | -|---|---|---|---| -| Review | general-purpose (requesting-code-review) | ~82k | ~4m | -| Review | nephila-core-conventions:code-eval | ~5k | ~2m | -| Review | nephila-core-conventions:doc-sync | ~3k | ~1m | - -**Considered & dropped:** low-level `mcp.server.lowlevel.Server` rewrite (hand-rolled -schemas/dispatch) — rejected as unnecessary boilerplate once the official SDK's -own FastMCP-equivalent (`MCPServer`) covered the same decorator ergonomics. -Aliasing the new class as `FastMCP` to minimize diff size — rejected in favor of -the real name for clarity to future readers. -**Follow-ups:** `docs/mcp.rst` was updated for the dependency description; no other -doc/config files referenced `fastmcp` by name. Optional (not done): an explicit -tool-count/import smoke test for the SDK swap, and a towncrier fragment for the -dependency change (feature is still unreleased on this branch, so not required). -**Refs:** #267. Eval: 87% — artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md diff --git a/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md b/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md deleted file mode 100644 index 8d5834c..0000000 --- a/artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md +++ /dev/null @@ -1,26 +0,0 @@ -# Evaluation — mcp-sdk-rewrite - -- **Date:** 2026-08-24 -- **Branch:** feature/issue-267-add-mcp (working tree, uncommitted) -- **Task:** #267 (follow-up: swap `fastmcp` for the official `mcp` SDK, `mcp~=2.0`) -- **Coverage:** partial — scoped to this task's diff only (`setup.cfg`, `taiga/mcp_server/server.py`, 2 files / 37+37 lines). Excludes the rest of the already-committed MCP feature on this branch, which was a separate prior deliverable. - -## Priority findings -- Documentation ≤ 2: `docs/mcp.rst:24-25` still describes `fastmcp` as the pulled-in dependency, contradicting the code now on `mcp~=2.0` — fix is queued in the immediately-following doc-sync step. - -## Scores -| Dimension | Score | Weight | Key evidence | -|---|---|---|---| -| Functionality | 5 | 20 | 66/66 tests pass against real `mcp~=2.0` in a scratch venv; stdio smoke test lists all 34 tools with instructions preserved verbatim. | -| Testing | 4 | 15 | Existing suite exercises every tool function directly and would fail at import if `MCPServer`/decorator form were wrong (reviewer confirmed); no explicit assertion of tool count/import success as a named test. | -| Security | 4 | 15 | No new input handling introduced; diff is import/class-name/decorator-form only (server.py:9,14,57...). | -| Code quality & best practices | 5 | 15 | Mechanical, minimal diff matching stated intent exactly; no stray bare `@mcp.tool` or leftover `fastmcp` refs (verified via grep). | -| Maintainability & flexibility | 5 | 15 | Matches upstream naming (`MCPServer`) rather than aliasing; drops one third-party dependency. | -| Error handling | N/A | 10 | Diff touches no error-handling paths (`auth.py`/`ConfigError` untouched). | -| Documentation | 2 | 10 | `docs/mcp.rst` still names `fastmcp` as the dependency (see priority finding above). | - -## Recommendations -- Documentation: run doc-sync now to update `docs/mcp.rst`'s install-extra description and the `pypi.org/project/fastmcp` link. - -## Total -**87%** — Clean, correctly-verified mechanical swap; the only real gap is a stale doc line already queued for the next step. diff --git a/artifacts/plans/2026-08-31-mcp-cli-parity.md b/artifacts/plans/2026-08-31-mcp-cli-parity.md deleted file mode 100644 index 9185e6e..0000000 --- a/artifacts/plans/2026-08-31-mcp-cli-parity.md +++ /dev/null @@ -1,789 +0,0 @@ -# Taiga MCP CLI Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give `taiga-mcp-server` the same CLI verb shape as `ring-mcp-server` — `serve`, `list-tools [--verbose]`, `call --json ''` — without touching any of the ~45 hand-implemented `@mcp.tool()` functions in `taiga/mcp_server/server.py`. - -**Architecture:** Rewrite `taiga/mcp_server/cli.py` from `argparse` to `typer`. `serve` preserves today's behavior byte-for-byte, now behind an explicit subcommand instead of the bare invocation. `list-tools` and `call` invoke the already-constructed `mcp` object in-process via `asyncio.run(mcp.list_tools())` / `asyncio.run(mcp.call_tool(name, arguments))` — no subprocess, no live MCP client round trip. - -**Tech Stack:** Python 3.11–3.14, `typer` (new dependency, `>=0.12.0` to match `ring-mcp-server`'s own floor), `mcp==2.0.0` (already pinned via the `[mcp]` extra), `pytest` + `typer.testing.CliRunner`. - -**Spec:** `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` - -## Global Constraints - -- Do not modify `taiga/mcp_server/server.py`, `taiga/mcp_server/auth.py`, or `taiga/mcp_server/serialize.py` — tool bodies, credential resolution, and serialization stay exactly as they are (spec §Non-goals). -- Do not rename any of the ~45 existing tool functions or their parameters (spec §Non-goals). -- `serve`'s auth flags/env-var precedence (flag > env > default) must remain identical to today's argparse behavior (spec §1). -- Console script stays `taiga-mcp-server = taiga.mcp_server.cli:main` in `setup.cfg` — no entry-point path change, `main()` just becomes a thin `app()` wrapper. -- Every new/changed behavior gets a test; no live Taiga server or network access in any test (spec §4). -- **Breaking change**: bare `taiga-mcp-server` (no subcommand) no longer starts the server. With Typer's `no_args_is_help=True` (same setting `ring-mcp-server`'s own CLI uses), it now prints the command list/help and exits 0 instead — this is a precision correction to the spec's "becomes a usage error" wording (see Task 6, which also amends the spec file itself for accuracy) — but it stops silently defaulting to `serve`, which is the compatibility break that matters. -- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on `feature/issue-267-add-mcp`. Do not rebase onto `master` as part of this plan — that happens later, once issue-267 merges (spec §Open items). - ---- - -## File Structure - -| File | Change | -|---|---| -| `taiga/mcp_server/cli.py` | Rewritten: argparse → Typer, 3 subcommands | -| `tests/test_mcp_server_cli.py` | Rewritten: `cli.main(argv)` calls → `CliRunner.invoke(cli.app, argv)` | -| `setup.cfg` | `[options.extras_require].mcp` gains `typer>=0.12.0` | -| `docs/mcp.rst` | Bare-invocation examples get ` serve`; new "Listing and calling tools directly" section | -| `AGENTS.md` | Two `claude mcp add ... -- taiga-mcp-server` examples get ` serve` | -| `changes/14039.feature` | New towncrier fragment | -| `changes/14039.removal` | New towncrier fragment (the breaking change) | -| `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` | One-sentence precision amendment (Task 6) | - -`_env_bool()` in `cli.py` is unchanged and reused as-is by the new `serve`/`list-tools`/`call` credential resolution — it has no Typer dependency, it's a pure env-var helper. - ---- - -## Task 1: Add the Typer dependency - -**Files:** -- Modify: `setup.cfg` - -**Interfaces:** -- Produces: `typer` importable wherever the `[mcp]` extra is installed — every later task in this plan depends on this. - -- [ ] **Step 1: Add the dependency** - -In `setup.cfg`, under `[options.extras_require]`: - -```ini -[options.extras_require] -docs = - sphinx - sphinx-rtd-theme -mcp = - mcp~=2.0 - typer>=0.12.0 -``` - -- [ ] **Step 2: Install it into the dev environment** - -Run: `pip install -e ".[mcp]"`, or `tox -e py313 --recreate` to rebuild the existing `.tox/py313` env (which already has `mcp` installed per the design's own investigation) so it picks up the new `typer` dependency from `setup.cfg`. - -- [ ] **Step 3: Verify the import works** - -Run: `python -c "import typer; print(typer.__version__)"` (or the equivalent inside the relevant tox env) — expect a version string, no `ImportError`. - -- [ ] **Step 4: Commit** - -```bash -git add setup.cfg -git commit -m "build(mcp): add typer dependency for the taiga-mcp-server CLI" -``` - ---- - -## Task 2: Rewrite `cli.py`'s skeleton and `serve` subcommand - -**Files:** -- Modify: `taiga/mcp_server/cli.py` (full rewrite) -- Test: `tests/test_mcp_server_cli.py` (rewrite the `main`-based tests; `_env_bool` tests are unchanged) - -**Interfaces:** -- Consumes: `taiga.mcp_server.auth.{DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure}` (all unchanged, from Task 1's untouched `auth.py`). -- Produces: `taiga.mcp_server.cli.app` (a `typer.Typer` instance — later tasks add commands to it), `taiga.mcp_server.cli.main() -> None` (console-script entry point), `taiga.mcp_server.cli._env_bool(name: str, default: bool) -> bool` (unchanged signature), `taiga.mcp_server.cli._resolve_credentials(host, token, token_type, username, password, tls_verify) -> Credentials` (new — later tasks reuse this for `list-tools` and `call`). - -- [ ] **Step 1: Write the failing tests for `serve`** - -Replace the `# --- main` section of `tests/test_mcp_server_cli.py` (keep the `_env_bool` tests above it untouched) with: - -```python -from typer.testing import CliRunner - -from taiga.mcp_server import cli - -runner = CliRunner() - -# --- serve ------------------------------------------------------------------------------ - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_configures_from_token_argv(mock_configure, mock_mcp): - result = runner.invoke( - cli.app, ["serve", "--host", "https://example.com", "--token", "tok", "--no-tls-verify"] - ) - - assert result.exit_code == 0 - mock_configure.assert_called_once() - credentials = mock_configure.call_args.args[0] - assert credentials.host == "https://example.com" - assert credentials.token == "tok" - assert credentials.tls_verify is False - mock_mcp.run.assert_called_once_with(transport="stdio") - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_configures_from_username_password_argv(mock_configure, mock_mcp): - runner.invoke(cli.app, ["serve", "--username", "alice", "--password", "secret", "--tls-verify"]) - - credentials = mock_configure.call_args.args[0] - assert credentials.username == "alice" - assert credentials.password == "secret" - assert credentials.token is None - assert credentials.tls_verify is True - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_reads_credentials_from_env(mock_configure, mock_mcp): - env = { - "TAIGA_HOST": "https://env.example.com", - "TAIGA_TOKEN": "env-tok", - "TAIGA_TOKEN_TYPE": "Basic", - } - with patch.dict("os.environ", env): - result = runner.invoke(cli.app, ["serve"]) - - assert result.exit_code == 0 - credentials = mock_configure.call_args.args[0] - assert credentials.host == "https://env.example.com" - assert credentials.token == "env-tok" - assert credentials.token_type == "Basic" - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_falls_back_to_tls_verify_env_var(mock_configure, mock_mcp): - with patch.dict("os.environ", {"TAIGA_TLS_VERIFY": "false"}): - runner.invoke(cli.app, ["serve", "--token", "tok"]) - - assert mock_configure.call_args.args[0].tls_verify is False - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_serve_defaults_tls_verify_true_without_env_or_flag(mock_configure, mock_mcp): - with patch.dict("os.environ", {}, clear=False): - os.environ.pop("TAIGA_TLS_VERIFY", None) - runner.invoke(cli.app, ["serve", "--token", "tok"]) - - assert mock_configure.call_args.args[0].tls_verify is True - - -# --- bare invocation (breaking change) --------------------------------------------------- - - -@patch("taiga.mcp_server.server.mcp") -@patch("taiga.mcp_server.cli.configure") -def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): - result = runner.invoke(cli.app, []) - - assert "serve" in result.output - mock_configure.assert_not_called() - mock_mcp.run.assert_not_called() - - -# --- --version -------------------------------------------------------------------------- - - -def test_version_flag_prints_version_and_exits(): - from taiga import __version__ - - result = runner.invoke(cli.app, ["--version"]) - - assert result.exit_code == 0 - assert __version__ in result.output -``` - -Delete the old `test_main_*` tests they replace (the argparse-specific ones: `test_main_configures_from_token_argv`, `test_main_configures_from_username_password_argv`, `test_main_reads_credentials_from_env`, `test_main_falls_back_to_tls_verify_env_var`, `test_main_defaults_tls_verify_true_without_env_or_flag`). - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: `ImportError`/`AttributeError` — `cli.app` doesn't exist yet (old `cli.py` is still argparse-based). - -- [ ] **Step 3: Rewrite `cli.py`** - -```python -# python-taiga -# Copyright 2015 Nephila -# See LICENSE for details. - -from __future__ import annotations - -import os -from typing import Optional - -import typer - -from .. import __version__ -from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure - -app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(f"taiga-mcp-server (python-taiga {__version__})") - raise typer.Exit() - - -@app.callback() -def _main( - version: Optional[bool] = typer.Option( - None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit." - ), -) -> None: - """Taiga MCP server & CLI.""" - - -def _env_bool(name: str, default: bool) -> bool: - value = os.environ.get(name) - if value is None: - return default - return value.strip().lower() not in ("0", "false", "no", "off") - - -def _resolve_credentials( - host: Optional[str], - token: Optional[str], - token_type: Optional[str], - username: Optional[str], - password: Optional[str], - tls_verify: Optional[bool], -) -> Credentials: - return Credentials( - host=host or os.environ.get("TAIGA_HOST", DEFAULT_HOST), - tls_verify=_env_bool("TAIGA_TLS_VERIFY", True) if tls_verify is None else tls_verify, - token=token or os.environ.get("TAIGA_TOKEN"), - token_type=token_type or os.environ.get("TAIGA_TOKEN_TYPE", DEFAULT_TOKEN_TYPE), - username=username or os.environ.get("TAIGA_USERNAME"), - password=password or os.environ.get("TAIGA_PASSWORD"), - ) - - -HostOption = typer.Option(None, help="Taiga instance host (default: TAIGA_HOST env var, or https://api.taiga.io).") -TokenOption = typer.Option(None, help="Taiga auth token (default: TAIGA_TOKEN env var).") -TokenTypeOption = typer.Option(None, help="Type of the auth token (default: TAIGA_TOKEN_TYPE env var, or Bearer).") -UsernameOption = typer.Option(None, help="Taiga username (default: TAIGA_USERNAME env var).") -PasswordOption = typer.Option(None, help="Taiga password (default: TAIGA_PASSWORD env var).") -TlsVerifyOption = typer.Option( - None, - "--tls-verify/--no-tls-verify", - help="Verify TLS certificates (default: TAIGA_TLS_VERIFY env var, or true).", -) - - -@app.command() -def serve( - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, -) -> None: - """Run the MCP server over stdio. - - Credentials can be passed as flags or read from the TAIGA_HOST/TAIGA_TOKEN - or TAIGA_HOST/TAIGA_USERNAME/TAIGA_PASSWORD environment variables. Passing - --token/--password on the command line can expose them via the process - list; prefer the environment variables where possible. - """ - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - mcp.run(transport="stdio") - - -def main() -> None: - """Entry point for the ``taiga-mcp-server`` console script.""" - app() - - -if __name__ == "__main__": - main() -``` - -This preserves the previous argparse CLI's `--version` flag (`action="version"`) via Typer's standard eager-callback idiom (`_main`'s `@app.callback()`), applying to the whole `app`, not just `serve`. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: PASS. (If `test_bare_invocation_no_longer_serves`'s exact exit code differs from what's asserted — the test above deliberately avoids asserting a specific exit code, only that `serve` wasn't triggered — no further action needed; if `"serve" in result.output` fails because Typer's help text formatting differs, inspect `result.output` and adjust the substring check, not the underlying behavior.) - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp)!: require explicit 'serve' subcommand for taiga-mcp-server - -BREAKING CHANGE: bare 'taiga-mcp-server' with no subcommand no longer -starts the MCP server. Existing MCP client configs invoking the binary -with no arguments must add ' serve'." -``` - ---- - -## Task 3: Add `list-tools` subcommand - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `taiga.mcp_server.cli.{app, HostOption, TokenOption, TokenTypeOption, UsernameOption, PasswordOption, TlsVerifyOption, _resolve_credentials}` from Task 2; `taiga.mcp_server.server.mcp.list_tools() -> list[mcp_types.Tool]` (async, verified during design — see spec §2). -- Produces: `taiga-mcp-server list-tools [--verbose/-v]` subcommand. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- list-tools --------------------------------------------------------------------------- - - -def test_list_tools_lists_all_tool_names(): - result = runner.invoke(cli.app, ["list-tools"]) - - assert result.exit_code == 0 - assert "whoami" in result.output - assert "list_user_stories" in result.output - assert "create_issue" in result.output - - -def test_list_tools_default_excludes_schema(): - result = runner.invoke(cli.app, ["list-tools"]) - - assert result.exit_code == 0 - assert '"properties"' not in result.output - - -def test_list_tools_verbose_includes_schema(): - result = runner.invoke(cli.app, ["list-tools", "--verbose"]) - - assert result.exit_code == 0 - assert '"properties"' in result.output -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` -Expected: FAIL — no `list-tools` command registered on `cli.app` yet (Typer/Click reports "No such command"). - -- [ ] **Step 3: Add the command** - -In `taiga/mcp_server/cli.py`, add near the top: - -```python -import asyncio -import json -``` - -(alongside the existing `import os`), and add the command itself after `serve`: - -```python -@app.command("list-tools") -def list_tools( - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, - verbose: bool = typer.Option(False, "--verbose", "-v", help="Include each tool's JSON input schema."), -) -> None: - """List every tool exposed by the MCP server.""" - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - tools = asyncio.run(mcp.list_tools()) - for tool in sorted(tools, key=lambda t: t.name): - dumped = tool.model_dump(by_alias=True, exclude_none=True) - typer.echo(f"{dumped['name']}\t{dumped.get('description', '')}") - if verbose: - typer.echo(json.dumps(dumped["inputSchema"], indent=2)) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -k list_tools -v` -Expected: PASS. - -- [ ] **Step 5: Run the full test file to check for regressions** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: all PASS (Task 2's `serve` tests unaffected). - -- [ ] **Step 6: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add 'list-tools' subcommand to taiga-mcp-server" -``` - ---- - -## Task 4: Add `call` subcommand — success path - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `taiga.mcp_server.server.mcp.call_tool(name, arguments, context=None) -> CallToolResult` (async; `.structured_content` / `.content` fields — verified during design, spec §2–3). -- Produces: `taiga-mcp-server call --json/-j ''` (happy path only — Task 5 adds the error matrix). - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- call: success path -------------------------------------------------------------------- - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_success_prints_structured_json_result(monkeypatch): - import taiga.mcp_server.server as server_mod - - monkeypatch.setattr(server_mod, "get_client", lambda: type("C", (), {"me": lambda self: {"id": 1, "username": "demo"}})()) - - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": 1, "username": "demo"} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` -Expected: FAIL — no `call` command registered yet. - -- [ ] **Step 3: Add the command** - -```python -@app.command() -def call( - tool_name: str = typer.Argument(..., help="Tool name, as shown by list-tools."), - arguments: str = typer.Option("{}", "--json", "-j", help="JSON object of arguments for the tool."), - host: Optional[str] = HostOption, - token: Optional[str] = TokenOption, - token_type: Optional[str] = TokenTypeOption, - username: Optional[str] = UsernameOption, - password: Optional[str] = PasswordOption, - tls_verify: Optional[bool] = TlsVerifyOption, -) -> None: - """Call a single tool directly, bypassing an MCP client.""" - try: - parsed_arguments = json.loads(arguments) - except json.JSONDecodeError as exc: - typer.echo(f"Invalid JSON in --json: {exc}", err=True) - raise typer.Exit(1) from exc - - configure(_resolve_credentials(host, token, token_type, username, password, tls_verify)) - - from .server import mcp - - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) - - payload = result.structured_content if result.structured_content is not None else result.content - typer.echo(json.dumps(payload, indent=2, default=str)) -``` - -(No error handling yet — that's Task 5. This step only makes the success-path test pass.) - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pytest tests/test_mcp_server_cli.py -k call_success -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add 'call' subcommand to taiga-mcp-server (success path)" -``` - ---- - -## Task 5: `call` subcommand — error matrix - -**Files:** -- Modify: `taiga/mcp_server/cli.py` -- Test: `tests/test_mcp_server_cli.py` - -**Interfaces:** -- Consumes: `mcp.server.mcpserver.exceptions.ToolError` (raised by `mcp.call_tool()` for unknown tool / validation failure / tool-internal exception, with `.__cause__` set to the underlying exception — verified live during design, spec §3); `mcp.shared.exceptions.MCPError` (unwrapped by the SDK, caught here defensively). - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_mcp_server_cli.py`: - -```python -# --- call: error matrix --------------------------------------------------------------------- - - -def test_call_invalid_json_errors(): - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{not valid"]) - - assert result.exit_code == 1 - assert "Invalid JSON in --json" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_unknown_tool_errors(): - result = runner.invoke(cli.app, ["call", "this_tool_does_not_exist", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Unknown tool: this_tool_does_not_exist" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_missing_required_argument_errors(): - result = runner.invoke(cli.app, ["call", "get_project", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Invalid arguments for get_project" in result.output - - -@patch("taiga.mcp_server.auth._client", None) -@patch("taiga.mcp_server.auth._credentials", None) -def test_call_tool_internal_exception_errors(monkeypatch): - for var in ("TAIGA_TOKEN", "TAIGA_USERNAME", "TAIGA_PASSWORD"): - monkeypatch.delenv(var, raising=False) - - result = runner.invoke(cli.app, ["call", "whoami", "--json", "{}"]) - - assert result.exit_code == 1 - assert "Error calling whoami" in result.output - assert "credentials" in result.output -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `pytest tests/test_mcp_server_cli.py -k "call_invalid_json or call_unknown_tool or call_missing_required or call_tool_internal" -v` -Expected: FAIL — `ToolError` currently propagates unhandled out of `call()`, causing `CliRunner` to report a non-zero exit but without the expected stderr message (Click captures the exception; `result.output` won't contain the intended text). - -- [ ] **Step 3: Add error handling** - -Add the import at the top of `cli.py`: - -```python -from mcp.server.mcpserver.exceptions import ToolError -from mcp.shared.exceptions import MCPError -from pydantic_core import ValidationError as PydanticValidationError -``` - -Wrap the `call_tool` invocation in `call()`: - -```python - try: - result = asyncio.run(mcp.call_tool(tool_name, parsed_arguments)) - except ToolError as exc: - cause = exc.__cause__ - message = str(exc) - if message.startswith("Unknown tool: "): - typer.echo(message, err=True) - elif isinstance(cause, PydanticValidationError): - typer.echo(f"Invalid arguments for {tool_name}: {cause}", err=True) - else: - typer.echo(f"Error calling {tool_name}: {cause if cause is not None else exc}", err=True) - raise typer.Exit(1) from exc - except MCPError as exc: - typer.echo(f"Error calling {tool_name}: {exc}", err=True) - raise typer.Exit(1) from exc - - payload = result.structured_content if result.structured_content is not None else result.content - typer.echo(json.dumps(payload, indent=2, default=str)) -``` - -(This replaces the bare `result = asyncio.run(...)` line from Task 4 with the `try/except` version; the two lines after it are unchanged.) - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `pytest tests/test_mcp_server_cli.py -v` -Expected: all PASS, including Task 4's success-path test and every earlier task's tests (full regression check). - -- [ ] **Step 5: Commit** - -```bash -git add taiga/mcp_server/cli.py tests/test_mcp_server_cli.py -git commit -m "feat(mcp): add error handling to taiga-mcp-server's 'call' subcommand" -``` - ---- - -## Task 6: Docs, changelog, and spec precision amendment - -**Files:** -- Modify: `docs/mcp.rst` -- Modify: `AGENTS.md` -- Create: `changes/14039.feature` -- Create: `changes/14039.removal` -- Modify: `artifacts/specs/2026-08-31-mcp-cli-parity-design.md` - -**Interfaces:** none (documentation-only task). - -- [ ] **Step 1: Update `docs/mcp.rst`'s "Running the server standalone" example** - -At `docs/mcp.rst:93-98`, change: - -```rst -.. code:: shell - - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server -``` - -to: - -```rst -.. code:: shell - - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server serve -``` - -- [ ] **Step 2: Update the "Connecting an MCP client" example** - -At `docs/mcp.rst:113-119`, change the last line of the `claude mcp add` block from: - -```rst - -- taiga-mcp-server -``` - -to: - -```rst - -- taiga-mcp-server serve -``` - -- [ ] **Step 3: Add a new "Listing and calling tools directly" section** - -Insert, right after the "Running the server standalone" section (after line 102, before the "Connecting an MCP client" heading at line 104): - -```rst -********************************** -Listing and calling tools directly -********************************** - -Outside of an MCP client, ``taiga-mcp-server`` also exposes its tool set -directly from a shell: - -.. code:: shell - - # list every tool, one per line - taiga-mcp-server list-tools - - # ...with each tool's JSON input schema - taiga-mcp-server list-tools --verbose - - # call a single tool by name, passing its arguments as a JSON object - TAIGA_HOST=https://taiga.example.com \ - TAIGA_USERNAME=myuser \ - TAIGA_PASSWORD=mypassword \ - taiga-mcp-server call whoami --json '{}' - - taiga-mcp-server call get_project --json '{"project": "myproject"}' - -On success, ``call`` prints the tool's JSON result to stdout. On failure -(unknown tool name, invalid arguments, or an error from the underlying -Taiga API call) it prints a message to stderr and exits with a non-zero -status. -``` - -- [ ] **Step 4: Update `AGENTS.md`** - -At `AGENTS.md`, in the two `claude mcp add` examples in step 4 (lines ~81-101), append ` serve` to the command in both: - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_USERNAME= \ - -e TAIGA_PASSWORD= \ - -- /absolute/path/to/taiga-mcp-server serve -``` - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_TOKEN= \ - -- /absolute/path/to/taiga-mcp-server serve -``` - -```bash - claude mcp add --scope user taiga \ - -e TAIGA_HOST=https://my.taiga.com \ - -e TAIGA_TOKEN= \ - -- uvx --from "python-taiga[mcp]" taiga-mcp-server serve -``` - -- [ ] **Step 5: Add towncrier changelog fragments** - -Create `changes/14039.feature`: - -``` -Add `list-tools` and `call` subcommands to `taiga-mcp-server`, letting tools be listed and invoked directly from a shell without an MCP client. -``` - -Create `changes/14039.removal`: - -``` -`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. -``` - -- [ ] **Step 6: Amend the spec's bare-invocation wording for accuracy** - -In `artifacts/specs/2026-08-31-mcp-cli-parity-design.md`, in the "Breaking change" section, replace: - -``` -**This design makes `serve` an explicit, required subcommand** — -bare invocation becomes a Typer usage error. This was a deliberate choice -(matching ring's shape exactly) made during design, not a byproduct. -``` - -with: - -``` -**This design makes `serve` an explicit, required subcommand** — bare -invocation no longer starts the server. With Typer's `no_args_is_help=True` -(the same setting ring-mcp-server's own CLI uses), it shows the command -list/help and exits 0, rather than becoming a hard usage error — the -compatibility break is that it no longer silently defaults to `serve`, not -the exact exit code. This was a deliberate choice (matching ring's shape -exactly) made during design, not a byproduct. -``` - -- [ ] **Step 7: Commit** - -```bash -git add docs/mcp.rst AGENTS.md changes/14039.feature changes/14039.removal artifacts/specs/2026-08-31-mcp-cli-parity-design.md -git commit -m "docs(mcp): document taiga-mcp-server's new serve/list-tools/call subcommands" -``` - ---- - -## Task 7: Final full-suite regression check - -**Files:** none (verification only). - -- [ ] **Step 1: Run the full test suite** - -Run: `tox -e py313` -Expected: all tests PASS, including every test from Tasks 2–5 and the pre-existing suite (`test_mcp_server.py`, `test_mcp_server_auth.py`, and the rest of the repo's tests untouched by this plan). - -- [ ] **Step 2: Run linting** - -Run: `tox -e ruff,black,isort` (the three lint/format-check envs defined in `tox.ini`) against the full repo. -Expected: no violations on `taiga/mcp_server/cli.py` or `tests/test_mcp_server_cli.py`. If `black`/`isort` report formatting diffs, run `tox -e blacken,isort_format` to auto-fix, then re-run the check envs. - -- [ ] **Step 3: Confirm no unintended changes to untouched files** - -Run: `git diff --stat feature/issue-267-add-mcp..HEAD` -Expected: only the files listed in this plan's "File Structure" table appear. diff --git a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md b/artifacts/specs/2026-08-31-mcp-cli-parity-design.md deleted file mode 100644 index af3a047..0000000 --- a/artifacts/specs/2026-08-31-mcp-cli-parity-design.md +++ /dev/null @@ -1,267 +0,0 @@ -# Design: CLI parity between python-taiga's MCP server and ring-mcp-server - -Date: 2026-08-31 -Status: Approved (design phase). Implementation plan to follow in this repo. -Origin: analysis and design were done from the `ring-mcp-server` repository -(comparing this project's `taiga/mcp_server/` against `ring-mcp-server`'s -CLI), then handed off and moved here since this is where the actual -implementation belongs. Taiga: us-14039. GitHub issue: 14039. - -## Context - -`ring-mcp-server` (github.com/nephila/ring_mcp) and this repo's -`taiga/mcp_server/` package are both MCP servers for Nephila tooling, but -architecturally opposite by design: - -- **ring-mcp-server**: generates its entire MCP tool set dynamically at - startup from a bundled OpenAPI 3.0 spec (`ring_mcp/spec.py`, - `ring_mcp/tools.py`). Tool names are the spec's `operationId`s verbatim - (dashes → underscores). This is intentional to that project and out of - scope here. -- **python-taiga** (this repo): hand-implements each of its ~45 (48 - including cross-cutting ones) Taiga operations as an individually - authored `@mcp.tool()`-decorated function in `taiga/mcp_server/server.py`, - using the official MCP SDK's `MCPServer` (`mcp.server.mcpserver`, - `mcp==2.0.0`). Tool name/description/input schema are all derived by the - SDK from the function signature and docstring. **This architecture must - not change** — that was an explicit constraint on this design. - -What differs today, and what this design closes, is the **CLI surface**: -ring-mcp-server exposes its full tool set through a small, fixed set of -generic CLI subcommands usable directly from a shell without an MCP client -(`serve`, `list-tools`, `call --json`, `fetch-token`). -`taiga-mcp-server` today does exactly one thing — start the MCP stdio -server — with no way to list or invoke a tool from a shell at all. - -## Goal - -Give `taiga-mcp-server` the same **CLI verb shape** and **invocation -method** as `ring-mcp-server`, without touching this repo's core -architecture (each Taiga operation stays a hand-written `@mcp.tool()` -function; no dynamic generation is introduced). - -## Non-goals (explicitly out of scope, confirmed during design) - -- **No renaming of existing tools.** The ~45 tool functions - (`list_user_stories`, `get_issue`, `create_task`, etc.) and their - parameters/`ref`-vs-`_by_id` addressing convention are untouched. Parity - is scoped to the CLI verbs and the JSON-blob invocation method only, not - to reshaping tool names toward ring's OpenAPI-operationId-identity style. -- **No `fetch-token` equivalent.** `auth.build_client()` already resolves - username/password to a session token internally and lazily on first tool - call. Taiga JWTs are typically short-lived (per this repo's own - `AGENTS.md`), so a separately printed, exportable token doesn't carry its - weight the way ring's DRF token does. Skipped. -- **No change to `taiga/mcp_server/server.py`'s tool bodies, `auth.py`'s - credential-resolution logic, or `serialize.py`.** This design touches only - `taiga/mcp_server/cli.py` (rewritten) and its tests/docs. - -## Breaking change (must be called out prominently) - -Today, bare `taiga-mcp-server` (no arguments) always starts the MCP stdio -server. **This design makes `serve` an explicit, required subcommand** — bare -invocation no longer starts the server. With Typer's `no_args_is_help=True` -(the same setting ring-mcp-server's own CLI uses), it shows the command -list/help and exits 0, rather than becoming a hard usage error — the -compatibility break is that it no longer silently defaults to `serve`, not -the exact exit code. This was a deliberate choice (matching ring's shape -exactly) made during design, not a byproduct. - -Impact: every existing MCP client config that invokes the binary with no -arguments (e.g. the `claude mcp add --scope user taiga ... -- taiga-mcp-server` -and `uvx --from "python-taiga[mcp]" taiga-mcp-server` examples currently -documented in this repo's own `AGENTS.md`) breaks and must add ` serve`. -This needs: - -- A major-version bump per this repo's own versioning/release mechanism - (`bump-my-version` per one of the branch names seen in `git branch -a` — - confirm exact tool/config during plan execution). -- A prominent breaking-change note in the CHANGELOG/release notes. -- Updated examples in `docs/mcp.rst` and `AGENTS.md` (see "Docs" below). - -## Design - -### 1. CLI structure (Typer) - -Rewrite `taiga/mcp_server/cli.py` from `argparse` to **Typer** (a new -dependency for this repo, chosen deliberately for implementation-style -consistency with ring-mcp-server over keeping argparse, per explicit design -decision — trade-off: one new runtime dependency plus rewriting the existing -flag-parsing logic). - -Three subcommands: - -``` -taiga-mcp-server serve - [--host HOST] [--token TOKEN] [--token-type TYPE] - [--username USER] [--password PASS] [--tls-verify/--no-tls-verify] - - Same auth flags, same env-var fallback (TAIGA_HOST/TAIGA_TOKEN/ - TAIGA_TOKEN_TYPE/TAIGA_USERNAME/TAIGA_PASSWORD/TAIGA_TLS_VERIFY), same - precedence (flag > env > default) as today's argparse implementation. - Calls auth.configure(...), then mcp.run(transport="stdio"). Behavior is - identical to today's default flow — only the verb is new. - -taiga-mcp-server list-tools [--verbose/-v] - [same auth flags as serve, for consistency — list-tools itself never - calls get_client(), so credentials aren't actually required to run it, - but auth.configure() is still invoked for a uniform command surface] - - Default: one line per tool, "name\tdescription", sorted by name. - --verbose: also pretty-prints each tool's JSON input schema. - -taiga-mcp-server call --json/-j '' - [same auth flags as serve — required here since most tools call - get_client()] - - Parses --json (default "{}") as the arguments dict, invokes the named - tool in-process, prints the JSON result to stdout, or an error to - stderr with exit code 1. -``` - -Each subcommand keeps its own copy of the auth option set (via a shared -Typer callback or small options dataclass) rather than global -pre-subcommand flags — idiomatic Typer, and keeps `serve`'s flag behavior -byte-for-byte compatible with today aside from requiring the verb. - -### 2. Invocation mechanics (verified against the installed SDK) - -`mcp.server.mcpserver.MCPServer` (`mcp==2.0.0`) is a distinct, purpose-built -class — not a `FastMCP` alias — exposing async in-process APIs confirmed by -direct inspection/execution against this repo's real `mcp` object -(`taiga.mcp_server.server.mcp`, using the `.tox/py313` env, which has the -`[mcp]` extra installed), with no live MCP client/transport round trip -required: - -```python -async def list_tools(self) -> list[mcp_types.Tool]: ... -async def call_tool(self, name: str, arguments: dict[str, Any], - context=None) -> CallToolResult | InputRequiredResult: ... -``` - -**`list-tools`:** -```python -tools = asyncio.run(mcp.list_tools()) -for t in sorted(tools, key=lambda t: t.name): - dumped = t.model_dump(by_alias=True, exclude_none=True) - print(f"{dumped['name']}\t{dumped.get('description', '')}") - if verbose: - print(json.dumps(dumped["inputSchema"], indent=2)) -``` -`model_dump(by_alias=True, exclude_none=True)` yields the wire-shaped keys -(`name`, `description`, `inputSchema`, `outputSchema`) exactly as an MCP -`ListTools` response would. Verified live: 48 tools registered today, e.g. -```json -{"name": "whoami", "description": "Return the Taiga user currently authenticated.", - "inputSchema": {"properties": {}, "title": "whoamiArguments", "type": "object"}, - "outputSchema": {"additionalProperties": true, "title": "whoamiDictOutput", "type": "object"}} -``` - -**`call`:** -```python -arguments = json.loads(json_str) # malformed JSON -> caught separately, see below -try: - result = asyncio.run(mcp.call_tool(tool_name, arguments)) -except ToolError as e: - ... # see error table below -else: - payload = result.structured_content if result.structured_content is not None else result.content - json.dump(payload, sys.stdout, indent=2, default=str) -``` - -`auth.configure(...)` runs before `asyncio.run(...)`, exactly as `serve` -does today, so `get_client()` inside tool bodies resolves credentials the -same way it does under a real MCP client. - -### 3. Error handling & output contract - -Mirrors ring's stderr-message-plus-`typer.Exit(1)` contract, mapped onto -this repo's actual failure shapes (all verified by direct execution against -the real `mcp` object during design): - -| Failure | Detection | stderr message | -|---|---|---| -| Malformed `--json` | `json.JSONDecodeError` | `Invalid JSON in --json: {exc}` | -| Unknown tool name | `ToolError` message starts with `"Unknown tool: "` | printed as-is | -| Argument validation failure | `ToolError` with `e.__cause__` a `pydantic_core.ValidationError` | `Invalid arguments for {tool_name}: {cause}` | -| Tool raised an application exception (`ConfigError`, `TaigaRestException`, etc.) | `ToolError` with any other `e.__cause__` | `Error calling {tool_name}: {cause}` (fallback to `str(e)` if `__cause__` is `None`) | -| Missing/invalid credentials at `serve`/`call` startup | `ConfigError` from `auth.build_client()` | `{exc}` (message already clear per `auth.py`) | -| Anything from `mcp.shared.exceptions.MCPError` (unwrapped by `call_tool()` per the SDK's own re-raise) | caught for safety even though not expected in normal use | same generic "Error calling {tool_name}: {cause}" formatting | - -All of the above: message to stderr, `raise typer.Exit(1)`. - -Verified failure shapes, captured live against the real `mcp` object -(against `whoami`, an unknown tool, and `get_project` with a missing -required argument): - -```python -await mcp.call_tool("whoami", {}) -# ToolError: "Error executing tool whoami: The Taiga MCP server has not -# been configured with any credentials." -# e.__cause__ -> ConfigError(...) - -await mcp.call_tool("this_tool_does_not_exist", {}) -# ToolError: "Unknown tool: this_tool_does_not_exist" - -await mcp.call_tool("get_project", {}) # missing required "project" arg -# ToolError: "Error executing tool get_project: 1 validation error for -# get_projectArguments ..." -# type(e.__cause__) -> pydantic_core.ValidationError -``` - -On success: `call` prefers `result.structured_content` (populated for every -tool here, since they all return dicts/lists via `to_jsonable()`), falling -back to `result.content` only if `structured_content` is `None`. Verified -live (with `get_client()` stubbed, since no live Taiga credentials were -available during design): -```python -result = await mcp.call_tool("whoami", {}) -# type(result) -> mcp_types._types.CallToolResult -# result.structured_content -> {'id': 1, 'username': 'demo'} -# result.is_error -> False -``` -Written via `json.dump(payload, sys.stdout, indent=2, default=str)`. - -### 4. Testing (scope; exact fixtures/layout to be confirmed against this -repo's existing `tests/` conventions when the plan is written) - -- **`serve`**: port existing argparse-flag-precedence tests to Typer's - `CliRunner`; add a test asserting bare invocation (no subcommand) now - exits non-zero instead of serving. -- **`list-tools`**: all tool names present, sorted; `--verbose` includes - each tool's `inputSchema`; runs without any credentials configured (never - calls `get_client()`). -- **`call`**: success path (stub/monkeypatch `get_client()`, assert stdout - JSON matches the tool's return value); malformed `--json`; unknown tool - name; missing required argument; tool-internal exception (e.g. - unconfigured-credentials `ConfigError`) — each asserting the exact stderr - message and exit code 1. -- No live network/Taiga server needed anywhere — everything runs in-process - against `mcp` with `get_client`/`TaigaAPI` stubbed, as verified during - design. - -### 5. Docs & migration - -- `docs/mcp.rst`: update every example showing bare `taiga-mcp-server` to - `taiga-mcp-server serve`; add a new subsection documenting `list-tools` - and `call`, styled after ring-mcp-server's own usage docs. -- `AGENTS.md`: update the two `claude mcp add ... -- taiga-mcp-server` / - `-- uvx --from "python-taiga[mcp]" taiga-mcp-server` examples (step 4) to - append ` serve`. -- CHANGELOG/release-notes mechanism for this repo (confirm exact convention - during plan execution) documenting the breaking change. - -## Open items for the implementation plan (not blocking this design) - -- Confirm this repo's exact test directory layout/fixtures for - `taiga/mcp_server/` before writing test cases. -- Confirm this repo's exact versioning/changelog mechanism for recording - the breaking change (a `chore/issue-140-switch-to-bump-my-version` branch - was seen in `git branch -a`, suggesting `bump-my-version` — verify). -- Confirm the Typer dependency is added correctly to `setup.cfg`'s `[mcp]` - extras (alongside the existing `mcp~=2.0` pin). -- This branch (`feature/issue-14039-taiga-mcp-cli-parity`) is based on - `feature/issue-267-add-mcp` (where `taiga/mcp_server/` currently lives, - unmerged to `master`) rather than `master` itself, since the package - doesn't exist on `master` yet. Rebase onto `master` once issue-267 merges, - before this branch is itself merged. diff --git a/changes/14039.removal b/changes/14039.removal index 3c2a823..8fba1db 100644 --- a/changes/14039.removal +++ b/changes/14039.removal @@ -1 +1 @@ -`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. +`taiga-mcp-server` now requires an explicit `serve` subcommand to start the MCP server. Running the bare command with no subcommand no longer starts it (it shows the command list instead) - update any MCP client configuration invoking it with no arguments to add ` serve`. `taiga.mcp_server.cli.main()`'s signature also changed, from `main(argv: list[str] | None = None) -> int` to `main() -> None` - this only affects code calling `main()` directly, not the `taiga-mcp-server` console script. diff --git a/docs/mcp.rst b/docs/mcp.rst index 8277d31..f416266 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -34,7 +34,7 @@ Any of the following also work, depending on your toolchain: pip install --user "python-taiga[mcp]" # no virtualenv management needed pipx install "python-taiga[mcp]" # isolated venv, one command on PATH - uvx --from "python-taiga[mcp]" taiga-mcp-server # no persistent install at all + uvx --from "python-taiga[mcp]" taiga-mcp-server --help # no persistent install at all Any of these makes a ``taiga-mcp-server`` console script available. diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 75aab3d..92a88c3 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -11,12 +11,17 @@ import typer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import MCPError -from pydantic_core import ValidationError as PydanticValidationError +from pydantic import ValidationError as PydanticValidationError from .. import __version__ from .auth import DEFAULT_HOST, DEFAULT_TOKEN_TYPE, Credentials, configure -app = typer.Typer(add_completion=False, no_args_is_help=True, help="Taiga MCP server & CLI.") +app = typer.Typer( + add_completion=False, + no_args_is_help=True, + help="Taiga MCP server & CLI. Prefer TAIGA_TOKEN/TAIGA_PASSWORD env vars over " + "--token/--password, which can be visible in the process list.", +) def _version_callback(value: bool) -> None: @@ -128,7 +133,11 @@ def call( password: str | None = PasswordOption, tls_verify: bool | None = TlsVerifyOption, ) -> None: - """Call a single tool directly, bypassing an MCP client.""" + """Call a single tool directly, bypassing an MCP client. + + Prefer the TAIGA_TOKEN/TAIGA_PASSWORD environment variables over + --token/--password, which can be visible in the process list. + """ try: parsed_arguments = json.loads(arguments) except json.JSONDecodeError as exc: diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index 2174d66..f7dd9f9 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -192,6 +192,7 @@ def test_bare_invocation_no_longer_serves(mock_configure, mock_mcp): result = runner.invoke(cli.app, []) assert "serve" in result.output + assert result.exit_code != 0 mock_configure.assert_not_called() mock_mcp.run.assert_not_called()