trueppm-mcp 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_mcp/__init__.py +41 -0
- trueppm_mcp/__main__.py +8 -0
- trueppm_mcp/cli.py +107 -0
- trueppm_mcp/client.py +486 -0
- trueppm_mcp/config.py +85 -0
- trueppm_mcp/py.typed +0 -0
- trueppm_mcp/server.py +186 -0
- trueppm_mcp/tools.py +993 -0
- trueppm_mcp-0.4.0.dist-info/METADATA +170 -0
- trueppm_mcp-0.4.0.dist-info/RECORD +16 -0
- trueppm_mcp-0.4.0.dist-info/WHEEL +4 -0
- trueppm_mcp-0.4.0.dist-info/entry_points.txt +2 -0
- trueppm_mcp-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_mcp-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_mcp-0.4.0.dist-info/licenses/NOTICES +49 -0
- trueppm_mcp-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
trueppm_mcp/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""trueppm-mcp — read-only Model Context Protocol server for self-hosted TruePPM.
|
|
2
|
+
|
|
3
|
+
A thin protocol adapter that talks to TruePPM only over HTTP (ADR-0186): it never
|
|
4
|
+
imports Django, never touches the ORM or database, and never imports from the
|
|
5
|
+
proprietary enterprise repo. RBAC is enforced once, at the API layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version as _pkg_version
|
|
12
|
+
|
|
13
|
+
from trueppm_mcp.client import (
|
|
14
|
+
ApiError,
|
|
15
|
+
AuthError,
|
|
16
|
+
NonAgentTokenError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TruePPMClient,
|
|
19
|
+
)
|
|
20
|
+
from trueppm_mcp.config import ConfigError, Settings
|
|
21
|
+
from trueppm_mcp.server import build_server
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Single source of truth: the version is whatever pip installed (set from
|
|
25
|
+
# pyproject.toml at build time), matching trueppm_scheduler's pattern —
|
|
26
|
+
# so this can't freeze at a stale literal the way trueppm_api's did.
|
|
27
|
+
__version__ = _pkg_version("trueppm-mcp")
|
|
28
|
+
except PackageNotFoundError: # running from an un-installed source tree
|
|
29
|
+
__version__ = "0.0.0+unknown"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ApiError",
|
|
33
|
+
"AuthError",
|
|
34
|
+
"ConfigError",
|
|
35
|
+
"NonAgentTokenError",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"Settings",
|
|
38
|
+
"TruePPMClient",
|
|
39
|
+
"__version__",
|
|
40
|
+
"build_server",
|
|
41
|
+
]
|
trueppm_mcp/__main__.py
ADDED
trueppm_mcp/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Command-line entry point for the TruePPM MCP server.
|
|
2
|
+
|
|
3
|
+
``trueppm-mcp`` (or ``python -m trueppm_mcp``) boots the server. stdio is the
|
|
4
|
+
primary transport — an AI client spawns this process and speaks MCP over the
|
|
5
|
+
pipe. ``--transport http`` (or ``sse``) runs a network transport for web-based
|
|
6
|
+
assistants.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from trueppm_mcp.client import TruePPMClient
|
|
17
|
+
from trueppm_mcp.config import ConfigError, Settings
|
|
18
|
+
from trueppm_mcp.server import (
|
|
19
|
+
ENV_ALLOWED_HOSTS,
|
|
20
|
+
build_server,
|
|
21
|
+
normalize_allowed_host,
|
|
22
|
+
parse_allowed_hosts,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
#: Map our CLI transport names onto FastMCP's literal transport identifiers.
|
|
26
|
+
_TRANSPORTS: dict[str, Literal["stdio", "sse", "streamable-http"]] = {
|
|
27
|
+
"stdio": "stdio",
|
|
28
|
+
"sse": "sse",
|
|
29
|
+
"http": "streamable-http",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _allowed_host_arg(value: str) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return normalize_allowed_host(value)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="trueppm-mcp",
|
|
43
|
+
description="Read-only MCP server for self-hosted TruePPM.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--transport",
|
|
47
|
+
choices=sorted(_TRANSPORTS),
|
|
48
|
+
default="stdio",
|
|
49
|
+
help="MCP transport to serve (default: stdio).",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--host",
|
|
53
|
+
default="127.0.0.1",
|
|
54
|
+
help="Bind host for the http/sse transports (default: 127.0.0.1).",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--port",
|
|
58
|
+
type=int,
|
|
59
|
+
default=8000,
|
|
60
|
+
help="Bind port for the http/sse transports (default: 8000).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--allowed-host",
|
|
64
|
+
action="append",
|
|
65
|
+
type=_allowed_host_arg,
|
|
66
|
+
default=[],
|
|
67
|
+
metavar="HOST",
|
|
68
|
+
help=(
|
|
69
|
+
"Extra Host name to accept on the http/sse transports, e.g. a "
|
|
70
|
+
"reverse-proxy hostname (repeatable). Also read, comma-separated, from "
|
|
71
|
+
f"{ENV_ALLOWED_HOSTS}. Loopback names are always accepted."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: list[str] | None = None) -> int:
|
|
78
|
+
"""Parse arguments, build the server, and run the chosen transport.
|
|
79
|
+
|
|
80
|
+
Returns a process exit code: ``0`` on a clean shutdown, ``2`` on a
|
|
81
|
+
configuration error (so a misconfigured client gets an actionable message on
|
|
82
|
+
stderr rather than a traceback). Authentication is verified by the server
|
|
83
|
+
lifespan at startup.
|
|
84
|
+
"""
|
|
85
|
+
args = _build_parser().parse_args(argv)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
settings = Settings.from_env()
|
|
89
|
+
except ConfigError as exc:
|
|
90
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
env_hosts = parse_allowed_hosts(os.environ.get(ENV_ALLOWED_HOSTS))
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
print(f"Configuration error: {ENV_ALLOWED_HOSTS}: {exc}", file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
# dict.fromkeys: de-duplicate while keeping env-then-flag order.
|
|
99
|
+
allowed_hosts = list(dict.fromkeys([*env_hosts, *args.allowed_host]))
|
|
100
|
+
client = TruePPMClient(settings)
|
|
101
|
+
server = build_server(client, host=args.host, port=args.port, allowed_hosts=allowed_hosts)
|
|
102
|
+
server.run(transport=_TRANSPORTS[args.transport])
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__": # pragma: no cover
|
|
107
|
+
raise SystemExit(main())
|
trueppm_mcp/client.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Async HTTP client for the TruePPM REST API.
|
|
2
|
+
|
|
3
|
+
The MCP server is *only* an HTTP client of the public REST API (ADR-0186 §C/§I):
|
|
4
|
+
it never imports Django, never touches the ORM or the database, and never imports
|
|
5
|
+
from the proprietary enterprise repo. Authorization — RBAC role gates,
|
|
6
|
+
member-scoped querysets, the 404-vs-403 existence oracle — is enforced once, at
|
|
7
|
+
the API layer, identically for this client and the web client. This module holds
|
|
8
|
+
no privileged path and is not the security boundary.
|
|
9
|
+
|
|
10
|
+
The bearer token is set as a request header and is never logged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import email.utils
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from trueppm_mcp.config import Settings
|
|
25
|
+
|
|
26
|
+
#: Connection-verification endpoint (relative to ``/api/v1/``). Returns the
|
|
27
|
+
#: identity of the token's owner; a 401 means the token does not authenticate.
|
|
28
|
+
AUTH_VERIFY_PATH = "auth/me/"
|
|
29
|
+
|
|
30
|
+
#: Per-request timeout (seconds). Read tools are simple GETs; a slow API should
|
|
31
|
+
#: surface as a clear error rather than hang the AI client indefinitely.
|
|
32
|
+
DEFAULT_TIMEOUT = 30.0
|
|
33
|
+
|
|
34
|
+
#: Cap on the number of rows a paginated read tool accumulates before it stops
|
|
35
|
+
#: following ``next`` and reports the result as truncated. The API paginates at
|
|
36
|
+
#: ``PAGE_SIZE`` (50) rows/page, so this follows up to ~20 pages. It bounds the
|
|
37
|
+
#: LLM context a single ``list_*`` tool can spend: a well-behaved list returns in
|
|
38
|
+
#: full, and a pathologically large one is capped with an explicit ``truncated``
|
|
39
|
+
#: signal rather than silently cut at the first page (#1731).
|
|
40
|
+
DEFAULT_MAX_ROWS = 1000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Refusal:
|
|
45
|
+
"""Why the API refused a call, as the API itself described it (#2689).
|
|
46
|
+
|
|
47
|
+
The API records a two-axis refusal taxonomy on every refused agent call and,
|
|
48
|
+
since ADR-0809, returns it in the 401/403 body. Before that, an operator saw a
|
|
49
|
+
bare HTTP status here and had to make a second, separate call to
|
|
50
|
+
``GET /agent-actions/?constraint=…`` to learn the reason — for the persona this
|
|
51
|
+
server exists to serve, a system that fails without saying why is the worst
|
|
52
|
+
outcome.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
verdict: Always ``"refused"`` today; carried so a caller can branch on one
|
|
56
|
+
field across the whole taxonomy rather than inferring it.
|
|
57
|
+
reason: The coarse axis — ``"identity"`` (the credential was rejected) or
|
|
58
|
+
``"policy"`` (the credential is fine, a guard denied the call).
|
|
59
|
+
constraint: The finer axis — which specific guard fired, e.g.
|
|
60
|
+
``"token_identity"`` or ``"capability_scope"``. Empty when the API
|
|
61
|
+
withheld it: the constraint vocabulary includes schedule-shaped codes
|
|
62
|
+
that could name a resource the caller may not read, so the API
|
|
63
|
+
discloses only from an allow-list. Absence is not an error.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
verdict: str
|
|
67
|
+
reason: str
|
|
68
|
+
constraint: str = ""
|
|
69
|
+
|
|
70
|
+
def describe(self) -> str:
|
|
71
|
+
"""One line an operator can act on, e.g. ``"policy refusal (capability_scope)"``."""
|
|
72
|
+
|
|
73
|
+
if self.constraint:
|
|
74
|
+
return f"{self.reason} refusal ({self.constraint})"
|
|
75
|
+
return f"{self.reason} refusal"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TruePPMError(RuntimeError):
|
|
79
|
+
"""Base for API-call failures, carrying the structured refusal when present."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, message: str, refusal: Refusal | None = None) -> None:
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
#: The parsed refusal taxonomy, or ``None`` when the API sent none — an
|
|
84
|
+
#: older server, or a failure that is not a governed refusal (a 404, a
|
|
85
|
+
#: 5xx). Callers must treat ``None`` as "no reason given", never as an
|
|
86
|
+
#: error in itself.
|
|
87
|
+
self.refusal = refusal
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ApiError(TruePPMError):
|
|
91
|
+
"""Raised when the API returns an unexpected (non-401) error status."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AuthError(TruePPMError):
|
|
95
|
+
"""Raised when the API rejects the configured bearer token (HTTP 401)."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NonAgentTokenError(TruePPMError):
|
|
99
|
+
"""Raised when the configured token authenticates (HTTP 200) but is not an
|
|
100
|
+
agent credential (#2938).
|
|
101
|
+
|
|
102
|
+
Distinct from :class:`AuthError`: the API did not reject the token — a
|
|
103
|
+
``legacy:full`` personal access token authenticates fine and always will,
|
|
104
|
+
because restraining it would restrain the person who owns it (#2877). What
|
|
105
|
+
this error catches is the *honest mistake*: someone pasted their own PAT into
|
|
106
|
+
``TRUEPPM_API_TOKEN`` instead of minting an ``mcp:read`` token. Left
|
|
107
|
+
unchecked, the server boots successfully on a credential the operator's
|
|
108
|
+
instance kill switch and the team's agent opt-out do not govern — a silent
|
|
109
|
+
bypass by configuration, not by attack. Raised by
|
|
110
|
+
:meth:`TruePPMClient.verify_auth`, before the server lifespan yields, so it
|
|
111
|
+
aborts the boot the same way a bad token does rather than letting every tool
|
|
112
|
+
call succeed unprotected.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RateLimitError(ApiError):
|
|
117
|
+
"""Raised when the API rate-limits the call (HTTP 429) — #2924.
|
|
118
|
+
|
|
119
|
+
A subclass of :class:`ApiError` so existing ``except ApiError`` handlers keep
|
|
120
|
+
working, but distinguishable for anything that wants to wait and retry.
|
|
121
|
+
|
|
122
|
+
This matters because it is not a rare edge: ``MCP_TOKEN_COMPUTE_RATE`` is
|
|
123
|
+
**12/min** and covers ``whatif``, ``monte-carlo/latest``, ``forecast`` and
|
|
124
|
+
``sprint-forecast`` — exactly the tools an agent exercises in a burst while
|
|
125
|
+
exploring a schedule. Folding that into a generic "Unexpected response …
|
|
126
|
+
HTTP 429." told the model a sentence it could not act on: it had no way to
|
|
127
|
+
know the call would succeed shortly, so it reported a nonspecific failure.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
retry_after: Seconds to wait, parsed from the ``Retry-After`` header, or
|
|
131
|
+
``None`` when the header was absent or unparseable. ``None`` means
|
|
132
|
+
"no hint given" — never treat it as "do not retry".
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
message: str,
|
|
138
|
+
refusal: Refusal | None = None,
|
|
139
|
+
retry_after: float | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
super().__init__(message, refusal)
|
|
142
|
+
self.retry_after = retry_after
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _http_date_delta(raw: str, date_header: str | None) -> float | None:
|
|
146
|
+
"""Seconds between an HTTP-date ``Retry-After`` and "now", or ``None`` if unusable.
|
|
147
|
+
|
|
148
|
+
"Now" is the response's own ``Date`` header when it sent a usable one, so client
|
|
149
|
+
clock skew cannot turn into a wildly wrong wait; local time is the fallback.
|
|
150
|
+
|
|
151
|
+
Total by design, and that is the contract callers depend on: every failure path
|
|
152
|
+
returns ``None`` and nothing escapes. ``parsedate_to_datetime`` *raises* on an
|
|
153
|
+
unparseable value rather than returning ``None``, so a junk header would
|
|
154
|
+
otherwise crash the 429 path that is already handling a failure.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
parsed = email.utils.parsedate_to_datetime(raw)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
if parsed is None: # pragma: no cover — defensive across versions
|
|
162
|
+
return None
|
|
163
|
+
now = None
|
|
164
|
+
if date_header:
|
|
165
|
+
# Same trap as above — a malformed Date header raises rather than
|
|
166
|
+
# returning None, and it must not crash the 429 path either.
|
|
167
|
+
try:
|
|
168
|
+
now = email.utils.parsedate_to_datetime(date_header)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
now = None
|
|
171
|
+
if now is None:
|
|
172
|
+
now = datetime.now(UTC)
|
|
173
|
+
try:
|
|
174
|
+
return (parsed - now).total_seconds()
|
|
175
|
+
except (TypeError, ValueError):
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_retry_after(response: httpx.Response) -> float | None:
|
|
180
|
+
"""Seconds to wait from a ``Retry-After`` header, or ``None`` if unusable.
|
|
181
|
+
|
|
182
|
+
RFC 9110 allows two forms and DRF sends the first, but a reverse proxy or CDN
|
|
183
|
+
in front of the instance may rewrite it to the second, so both are handled:
|
|
184
|
+
|
|
185
|
+
* delta-seconds — a non-negative integer (``Retry-After: 30``)
|
|
186
|
+
* an HTTP-date — absolute (``Retry-After: Wed, 21 Oct 2026 07:28:00 GMT``),
|
|
187
|
+
converted to a delta against the response's own ``Date`` header when it has
|
|
188
|
+
one, and against local time otherwise. Using the server's ``Date`` avoids
|
|
189
|
+
turning client clock skew into a wildly wrong wait.
|
|
190
|
+
|
|
191
|
+
Total by design: anything unparseable, negative, or absurd yields ``None``.
|
|
192
|
+
A malformed header must degrade to "no hint", never crash the call that is
|
|
193
|
+
already failing.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
raw = response.headers.get("Retry-After")
|
|
197
|
+
if not raw:
|
|
198
|
+
return None
|
|
199
|
+
raw = raw.strip()
|
|
200
|
+
try:
|
|
201
|
+
seconds = float(int(raw))
|
|
202
|
+
except ValueError:
|
|
203
|
+
delta = _http_date_delta(raw, response.headers.get("Date"))
|
|
204
|
+
if delta is None:
|
|
205
|
+
return None
|
|
206
|
+
seconds = delta
|
|
207
|
+
if seconds < 0:
|
|
208
|
+
return None
|
|
209
|
+
return seconds
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_refusal(response: httpx.Response) -> Refusal | None:
|
|
213
|
+
"""Extract the refusal envelope from an error response, if it carries one.
|
|
214
|
+
|
|
215
|
+
Total by design: a body that is not JSON, not an object, or carries no
|
|
216
|
+
``refusal`` key yields ``None``. A refusal that cannot be parsed must never
|
|
217
|
+
turn a clean 403 into a client-side crash — the HTTP status is still the
|
|
218
|
+
answer, and the envelope is additional explanation.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
body = response.json()
|
|
223
|
+
except ValueError:
|
|
224
|
+
return None
|
|
225
|
+
if not isinstance(body, dict):
|
|
226
|
+
return None
|
|
227
|
+
envelope = body.get("refusal")
|
|
228
|
+
if not isinstance(envelope, dict):
|
|
229
|
+
return None
|
|
230
|
+
reason = envelope.get("reason")
|
|
231
|
+
if not isinstance(reason, str) or not reason:
|
|
232
|
+
return None
|
|
233
|
+
verdict = envelope.get("verdict")
|
|
234
|
+
constraint = envelope.get("constraint")
|
|
235
|
+
return Refusal(
|
|
236
|
+
verdict=verdict if isinstance(verdict, str) else "refused",
|
|
237
|
+
reason=reason,
|
|
238
|
+
constraint=constraint if isinstance(constraint, str) else "",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _rate_limit_message(path: str, retry_after: float | None) -> str:
|
|
243
|
+
"""The one sentence the model acts on: that it is rate-limited, and for how long.
|
|
244
|
+
|
|
245
|
+
The wait is stated explicitly rather than left for the model to infer, because
|
|
246
|
+
"HTTP 429" alone reads as a failure and the correct response — wait a moment
|
|
247
|
+
and repeat the same call — is not derivable from it.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
if retry_after is None:
|
|
251
|
+
return (
|
|
252
|
+
f"Rate limited by the TruePPM API on {path} (HTTP 429). No Retry-After "
|
|
253
|
+
"header was sent; wait a few seconds and retry the same call."
|
|
254
|
+
)
|
|
255
|
+
return (
|
|
256
|
+
f"Rate limited by the TruePPM API on {path} (HTTP 429). Retry the same call "
|
|
257
|
+
f"in {retry_after:.0f} second(s)."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _refusal_suffix(refusal: Refusal | None) -> str:
|
|
262
|
+
"""`" — policy refusal (capability_scope)"`, or empty when nothing was sent."""
|
|
263
|
+
|
|
264
|
+
return f" — {refusal.describe()}" if refusal is not None else ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class TruePPMClient:
|
|
268
|
+
"""Thin async wrapper over ``httpx.AsyncClient`` carrying the bearer token.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
settings: Resolved :class:`~trueppm_mcp.config.Settings`.
|
|
272
|
+
transport: Optional ``httpx`` transport. Injected in tests
|
|
273
|
+
(``httpx.MockTransport``); ``None`` uses the real network transport.
|
|
274
|
+
timeout: Per-request timeout in seconds.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
settings: Settings,
|
|
280
|
+
*,
|
|
281
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
282
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
283
|
+
) -> None:
|
|
284
|
+
# A trailing slash on the base URL makes httpx's RFC-3986 relative join
|
|
285
|
+
# append the path segment instead of replacing the last one, so
|
|
286
|
+
# "auth/me/" resolves to ".../api/v1/auth/me/".
|
|
287
|
+
self._client = httpx.AsyncClient(
|
|
288
|
+
base_url=f"{settings.api_base_url}/",
|
|
289
|
+
headers={
|
|
290
|
+
"Authorization": f"Bearer {settings.token}",
|
|
291
|
+
"Accept": "application/json",
|
|
292
|
+
},
|
|
293
|
+
timeout=timeout,
|
|
294
|
+
transport=transport,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def verify_auth(self) -> dict[str, Any]:
|
|
298
|
+
"""Confirm the token authenticates by calling ``GET /auth/me/``.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
The identity payload (the token owner's user record) on HTTP 200.
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
AuthError: On HTTP 401 — the token is missing, malformed, or revoked.
|
|
305
|
+
ApiError: On any other non-success status.
|
|
306
|
+
NonAgentTokenError: The token authenticates but is not an agent
|
|
307
|
+
credential (#2938) — see that class's docstring.
|
|
308
|
+
"""
|
|
309
|
+
response = await self._client.get(AUTH_VERIFY_PATH)
|
|
310
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
311
|
+
# No token material in the message — only the fact of rejection, plus
|
|
312
|
+
# whatever reason the API chose to disclose.
|
|
313
|
+
refusal = parse_refusal(response)
|
|
314
|
+
raise AuthError(
|
|
315
|
+
"The TruePPM API rejected the configured token (HTTP 401)"
|
|
316
|
+
f"{_refusal_suffix(refusal)}.",
|
|
317
|
+
refusal,
|
|
318
|
+
)
|
|
319
|
+
if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
|
|
320
|
+
retry_after = parse_retry_after(response)
|
|
321
|
+
raise RateLimitError(
|
|
322
|
+
_rate_limit_message(AUTH_VERIFY_PATH, retry_after),
|
|
323
|
+
parse_refusal(response),
|
|
324
|
+
retry_after,
|
|
325
|
+
)
|
|
326
|
+
if response.is_error:
|
|
327
|
+
refusal = parse_refusal(response)
|
|
328
|
+
raise ApiError(
|
|
329
|
+
f"Unexpected response from {AUTH_VERIFY_PATH}: "
|
|
330
|
+
f"HTTP {response.status_code}{_refusal_suffix(refusal)}.",
|
|
331
|
+
refusal,
|
|
332
|
+
)
|
|
333
|
+
result: dict[str, Any] = response.json()
|
|
334
|
+
posture = result.get("token")
|
|
335
|
+
is_agent = isinstance(posture, dict) and bool(posture.get("is_agent"))
|
|
336
|
+
if not is_agent:
|
|
337
|
+
scopes = posture.get("scopes") if isinstance(posture, dict) else None
|
|
338
|
+
raise NonAgentTokenError(
|
|
339
|
+
"This token is a full-access personal credential"
|
|
340
|
+
f"{f' (scopes: {scopes})' if scopes else ''}, not an agent "
|
|
341
|
+
"credential — the instance kill switch and your team's agent "
|
|
342
|
+
"opt-out will not apply to it. Mint an mcp:read token instead: "
|
|
343
|
+
"Personal Access Tokens → Create token → 'Read-only for AI assistants'."
|
|
344
|
+
)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
async def get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
|
|
348
|
+
"""Issue an authenticated ``GET`` and return the decoded JSON body.
|
|
349
|
+
|
|
350
|
+
Every read tool routes through here (ADR-0186 §D/§I): the request is a
|
|
351
|
+
plain ``GET`` against ``/api/v1/<path>`` carrying the bearer token, so
|
|
352
|
+
authorization is enforced once, at the API layer, exactly as for the web
|
|
353
|
+
client. This method holds no privileged path.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
path: Path relative to the ``/api/v1/`` base (e.g. ``"projects/"`` or
|
|
357
|
+
``"projects/{id}/forecast/"``). No leading slash.
|
|
358
|
+
params: Optional query parameters. Callers omit ``None`` values; a
|
|
359
|
+
filter left unset is simply not sent.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
The decoded JSON body — a ``dict`` for object endpoints, a ``list``
|
|
363
|
+
or paginated ``dict`` for collection endpoints.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
AuthError: On HTTP 401 — the token is missing, malformed, or revoked.
|
|
367
|
+
The message never contains token material.
|
|
368
|
+
ApiError: On any other error status (404 for a resource the caller
|
|
369
|
+
cannot read, 5xx, etc.).
|
|
370
|
+
"""
|
|
371
|
+
response = await self._client.get(path, params=dict(params) if params else None)
|
|
372
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
373
|
+
# No token material in the message — only the fact of rejection, plus
|
|
374
|
+
# whatever reason the API chose to disclose.
|
|
375
|
+
refusal = parse_refusal(response)
|
|
376
|
+
raise AuthError(
|
|
377
|
+
"The TruePPM API rejected the configured token (HTTP 401)"
|
|
378
|
+
f"{_refusal_suffix(refusal)}.",
|
|
379
|
+
refusal,
|
|
380
|
+
)
|
|
381
|
+
if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
|
|
382
|
+
# Not folded into the generic branch below: the compute bucket is
|
|
383
|
+
# 12/min, so this is the routine outcome of an exploratory burst and
|
|
384
|
+
# the model needs a wait hint rather than an opaque status (#2924).
|
|
385
|
+
retry_after = parse_retry_after(response)
|
|
386
|
+
raise RateLimitError(
|
|
387
|
+
_rate_limit_message(path, retry_after),
|
|
388
|
+
parse_refusal(response),
|
|
389
|
+
retry_after,
|
|
390
|
+
)
|
|
391
|
+
if response.is_error:
|
|
392
|
+
refusal = parse_refusal(response)
|
|
393
|
+
raise ApiError(
|
|
394
|
+
f"Unexpected response from {path}: "
|
|
395
|
+
f"HTTP {response.status_code}{_refusal_suffix(refusal)}.",
|
|
396
|
+
refusal,
|
|
397
|
+
)
|
|
398
|
+
return response.json()
|
|
399
|
+
|
|
400
|
+
async def get_paginated(
|
|
401
|
+
self,
|
|
402
|
+
path: str,
|
|
403
|
+
params: Mapping[str, Any] | None = None,
|
|
404
|
+
*,
|
|
405
|
+
max_rows: int = DEFAULT_MAX_ROWS,
|
|
406
|
+
) -> tuple[list[Any], int, bool]:
|
|
407
|
+
"""Follow DRF pagination, accumulating rows up to ``max_rows`` (#1731).
|
|
408
|
+
|
|
409
|
+
The list tools must not silently reason over only the first page: a DRF
|
|
410
|
+
collection returns ``{count, next, previous, results}`` at ``PAGE_SIZE``
|
|
411
|
+
(50) rows/page, so a bare ``results`` read truncates at 50 with no signal.
|
|
412
|
+
This method follows the absolute ``next`` links until it has ``max_rows``
|
|
413
|
+
rows or runs out of pages, and reports whether more rows exist server-side
|
|
414
|
+
so the caller can tell the model the set is partial.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
path: Path relative to the ``/api/v1/`` base (e.g. ``"tasks/"``).
|
|
418
|
+
params: Query parameters for the first page. ``next`` carries its own
|
|
419
|
+
params, so following pages send none of their own.
|
|
420
|
+
max_rows: Stop accumulating once this many rows are collected.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
``(rows, total_count, truncated)``:
|
|
424
|
+
* ``rows`` — the accumulated result rows, capped at ``max_rows``.
|
|
425
|
+
* ``total_count`` — the server's reported ``count`` (the true total),
|
|
426
|
+
or ``len(rows)`` for an unpaginated (bare-list) body.
|
|
427
|
+
* ``truncated`` — ``True`` when fewer rows were returned than exist
|
|
428
|
+
server-side (``next`` remained, or the cap was hit).
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
AuthError: On HTTP 401 (see :meth:`get`).
|
|
432
|
+
ApiError: On any other error status (see :meth:`get`).
|
|
433
|
+
"""
|
|
434
|
+
payload = await self.get(path, params=params)
|
|
435
|
+
# An endpoint that returns a bare JSON list is unpaginated; the whole body
|
|
436
|
+
# is the row set (still cap it defensively).
|
|
437
|
+
if isinstance(payload, list):
|
|
438
|
+
return payload[:max_rows], len(payload), len(payload) > max_rows
|
|
439
|
+
if not isinstance(payload, Mapping) or "results" not in payload:
|
|
440
|
+
return [], 0, False
|
|
441
|
+
|
|
442
|
+
rows: list[Any] = []
|
|
443
|
+
results = payload.get("results")
|
|
444
|
+
if isinstance(results, list):
|
|
445
|
+
rows.extend(results)
|
|
446
|
+
total_count = payload.get("count")
|
|
447
|
+
next_url = payload.get("next")
|
|
448
|
+
# ``next`` is an absolute URL carrying its own page/query params, so it is
|
|
449
|
+
# passed to get() as the full path with no additional params.
|
|
450
|
+
while isinstance(next_url, str) and next_url and len(rows) < max_rows:
|
|
451
|
+
page = await self.get(next_url)
|
|
452
|
+
if not isinstance(page, Mapping):
|
|
453
|
+
break
|
|
454
|
+
page_results = page.get("results")
|
|
455
|
+
if not isinstance(page_results, list) or not page_results:
|
|
456
|
+
# A well-behaved DRF page never advertises ``next`` past the last
|
|
457
|
+
# populated page. Stop on the first empty/malformed page so a
|
|
458
|
+
# misbehaving server that keeps returning a non-null ``next`` with
|
|
459
|
+
# no rows can't spin this loop — it stays provably bounded.
|
|
460
|
+
break
|
|
461
|
+
rows.extend(page_results)
|
|
462
|
+
next_url = page.get("next")
|
|
463
|
+
|
|
464
|
+
truncated = (isinstance(next_url, str) and bool(next_url)) or len(rows) > max_rows
|
|
465
|
+
rows = rows[:max_rows]
|
|
466
|
+
if not isinstance(total_count, int):
|
|
467
|
+
total_count = len(rows)
|
|
468
|
+
# A count larger than what we returned is truncation even if we exhausted
|
|
469
|
+
# ``next`` (defensive against an inconsistent count/next).
|
|
470
|
+
truncated = truncated or total_count > len(rows)
|
|
471
|
+
return rows, total_count, truncated
|
|
472
|
+
|
|
473
|
+
async def aclose(self) -> None:
|
|
474
|
+
"""Close the underlying connection pool."""
|
|
475
|
+
await self._client.aclose()
|
|
476
|
+
|
|
477
|
+
async def __aenter__(self) -> TruePPMClient:
|
|
478
|
+
return self
|
|
479
|
+
|
|
480
|
+
async def __aexit__(
|
|
481
|
+
self,
|
|
482
|
+
exc_type: type[BaseException] | None,
|
|
483
|
+
exc: BaseException | None,
|
|
484
|
+
tb: TracebackType | None,
|
|
485
|
+
) -> None:
|
|
486
|
+
await self.aclose()
|