Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ debian/python3-taiga*
.venv
*.egg-link
.superpowers
artifacts
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,22 @@ globally" / "add it to my user-wide config", follow this procedure:
-e TAIGA_HOST=https://my.taiga.com \
-e TAIGA_USERNAME=<username> \
-e TAIGA_PASSWORD=<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=<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:
```bash
claude mcp add --scope user taiga \
-e TAIGA_HOST=https://my.taiga.com \
-e TAIGA_TOKEN=<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.
Expand Down
42 changes: 0 additions & 42 deletions artifacts/activity-log.md

This file was deleted.

26 changes: 0 additions & 26 deletions artifacts/evaluations/2026-08-24-mcp-sdk-rewrite.md

This file was deleted.

1 change: 1 addition & 0 deletions changes/14039.feature
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changes/14039.removal
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 31 additions & 3 deletions docs/mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
*****************************
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ docs =
sphinx-rtd-theme
mcp =
mcp~=2.0
typer>=0.12.0

[sdist]
formats = zip
Expand Down
198 changes: 150 additions & 48 deletions taiga/mcp_server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Comment on lines +141 to +145

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()
Loading
Loading