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/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/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/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..8fba1db --- /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`. `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 dd2b926..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. @@ -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: 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 diff --git a/taiga/mcp_server/cli.py b/taiga/mcp_server/cli.py index 3cff5c5..92a88c3 100644 --- a/taiga/mcp_server/cli.py +++ b/taiga/mcp_server/cli.py @@ -4,13 +4,40 @@ from __future__ import annotations -import argparse +import asyncio +import json import os -import sys + +import typer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +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. Prefer TAIGA_TOKEN/TAIGA_PASSWORD env vars over " + "--token/--password, which can be visible in the process list.", +) + + +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 +46,132 @@ 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 + + +@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)) + + +@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. + + 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: + 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 + + 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)) + + +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..f7dd9f9 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -1,10 +1,15 @@ from __future__ import annotations +import json import os from unittest.mock import patch +from typer.testing import CliRunner + from taiga.mcp_server import cli +runner = CliRunner() + # --- _env_bool ------------------------------------------------------------------------------ @@ -27,15 +32,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 +51,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 +63,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 +81,129 @@ 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 + + +# --- 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 + + +# --- 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"} + + +# --- 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) --------------------------------------------------- + + +@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 + assert result.exit_code != 0 + 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