Skip to content

MCP server

This is the operator’s reference for trueppm-mcp, the read-only Model Context Protocol server. For the user-facing feature overview — what it answers and how it wires into an AI client — see MCP server (read-only). This page covers the operational side: how to configure it, which transport to run, how to deploy it in a container, and what its security posture is.

trueppm-mcp is a thin protocol adapter. It is not part of the API deployment — it runs as a separate process that talks to TruePPM only over HTTP via the public REST API, carrying a personal mcp:read access token as a bearer credential. It never imports Django, never touches the database or the ORM, and holds no privileged path: your role-based permissions are enforced exactly once, at the API layer, identically for this server and the web client.

There are two placement models, and the transport you choose follows from the placement:

  • Next to the AI client (stdio). The client — Claude Desktop, Cursor, Zed — spawns trueppm-mcp as a local subprocess and speaks MCP over the pipe. This is the primary model and the default transport. Nothing listens on a network port.
  • As a network service (HTTP or SSE). For a web-based or shared assistant that cannot spawn a local subprocess, run trueppm-mcp as a long-lived process (or container) that listens on a port. The AI client connects over Streamable HTTP or SSE.

The server authenticates with a personal access token (tppm_<64-hex>) carrying the mcp:read scope. Mint one from Personal Settings → API tokens; the raw token is shown once, so copy it immediately. Only the SHA-256 digest is stored server-side.

The read surface accepts only owner-scoped (personal) tokens — a project- or program-scoped token is rejected here, so it can never be turned into a credential that reads beyond the single scope it was minted for. Choose the mcp:read scope and set an expiry (required for mcp:read). That scope grants safe-method (GET) access to the viewsets the MCP wraps and is rejected at every write path, and the token acts as you: it reads only what your role permits, cannot write, and cannot outlive its expiry. The unrestricted legacy:full token is for inbound sync and is refused on this surface.

The server is configured entirely from the environment — there is no config file on disk, which keeps it spawnable as a subprocess with no state to manage.

VariableRequiredDescription
TRUEPPM_API_URLyesBase URL of your instance, e.g. https://ppm.example.com. The /api/v1 suffix is appended automatically if you omit it (and is idempotent if you include it).
TRUEPPM_API_TOKENyesA personal access token (tppm_<64-hex>) with the mcp:read scope and an expiry.

If either variable is missing or blank, the process exits immediately with exit code 2 and an actionable message on stderr — it names the absent variable and never echoes the token. On startup the server calls GET /api/v1/auth/me/ once to confirm the token authenticates, so a bad token fails the boot with a clear error rather than letting every query 401 later.

Select the transport with --transport. --host and --port apply only to the network transports and are ignored for stdio.

FlagTransportDefault host / portUse it when
--transport stdiostdio (default)The AI client spawns the server as a local subprocess.
--transport httpStreamable HTTP127.0.0.1:8000A web/shared assistant connects over HTTP.
--transport sseServer-Sent Events127.0.0.1:8000A client that only speaks the older SSE transport.
Terminal window
pip install trueppm-mcp
TRUEPPM_API_URL=https://ppm.example.com \
TRUEPPM_API_TOKEN=tppm_your_token_here \
trueppm-mcp # stdio; Ctrl-C to stop

For wiring stdio into claude_desktop_config.json, see the feature guide.

Terminal window
TRUEPPM_API_URL=https://ppm.example.com \
TRUEPPM_API_TOKEN=tppm_your_token_here \
trueppm-mcp --transport http --host 127.0.0.1 --port 8000

The default bind host is 127.0.0.1 — loopback only — so an accidental launch never exposes the server on a public interface. Bind to 0.0.0.0 only behind a reverse proxy that terminates TLS and controls access; the server itself speaks plain HTTP and holds a live credential, so it must never face the public internet directly.

Quickstart for non-Python clients (raw HTTP)

Section titled “Quickstart for non-Python clients (raw HTTP)”

trueppm-mcp is a Python package, but nothing about the wire protocol is Python-specific. This section is a worked curl transcript against the Streamable HTTP transport so a TypeScript, Go, or Rust agent author can build against it without reverse-engineering the handshake. Use an MCP SDK for real work — this is the paved path for understanding what the SDK does, and for debugging when it misbehaves.

Start the server on loopback first:

Terminal window
TRUEPPM_API_URL=https://ppm.example.com \
TRUEPPM_API_TOKEN=tppm_your_token_here \
trueppm-mcp --transport http --host 127.0.0.1 --port 8000

The Streamable HTTP endpoint is POST /mcp. Two request headers are mandatory on every call:

  • Content-Type: application/json
  • Accept: application/json, text/event-stream — responses are framed as SSE events even for a single reply. Omitting text/event-stream returns 406.
Terminal window
curl -i -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"my-agent","version":"1.0"}}}'

The response carries the session id in a header — capture it, every later request needs it:

HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 9107b4c7e7014f9d86ac599e3b812065
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false}, ...},
"serverInfo":{"name":"trueppm","version":"1.28.1"},
"instructions":"Read-only access to a self-hosted TruePPM instance: ..."}}

A request without Mcp-Session-Id returns 400.

Terminal window
curl -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

This is a notification, not a request — no id, and the server answers 202 with an empty body.

Terminal window
curl -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"list_projects","description":"List every project you can read...",
"inputSchema":{"properties":{},"type":"object"},
"outputSchema":{"type":"object","additionalProperties":true}}, ...]}}
Terminal window
curl -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"list_projects","arguments":{}}}'

Every tool result arrives twice over: content (the JSON rendered as text, for models that only read text) and structuredContent (the same payload as real JSON). Parse structuredContent.

event: message
data: {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"{\n \"items\": [],\n \"total_count\": 0\n}"}],
"structuredContent":{"items":[],"total_count":0},
"isError":false}}

The primary-answer tools — get_task, get_schedule_summary, get_monte_carlo_forecast, and whatif — attach a why block inside structuredContent. It is TruePPM’s “computed, not guessed” evidence: the server-side reason behind the number the tool just returned.

{
"p80": "2026-11-14",
"cpm_finish": "2026-10-30",
"why": {
"top_driver": {"task_id": "", "index": 0.62},
"explanation": "This forecast: the P80 finish 2026-11-14 is 11 working day(s) past the deterministic CPM finish 2026-10-30; the largest single driver of that spread is task ….",
"see_also": "get_schedule_derivation(project_id, quantity='p50'|'p80'|'p95') for the full risk-premium and per-driver derivation"
}
}

Two properties make why cheap to consume: it never triggers a recompute or an extra API call (it is distilled from fields the tool already fetched), and it is scope-safe — it re-presents the token’s own permission-filtered payload, so it can never surface a field the token cannot see. It is omitted entirely rather than fabricated when the inputs are absent, so treat it as optional.

--transport sse serves the older two-endpoint transport instead: open a GET stream on /sse and post messages to /messages/. Prefer Streamable HTTP for anything new; use SSE only for a client that cannot speak the newer one.

The package ships a Dockerfile (packages/mcp/Dockerfile). It is a two-stage, non-root image with the Python build tools removed from the runtime layer, so it scans clean and runs as an unprivileged user (UID 1000). Build it from the package directory:

Terminal window
docker build -t trueppm-mcp packages/mcp/

Run it in stdio mode — pass -i so the client can drive it over the pipe:

Terminal window
docker run --rm -i \
-e TRUEPPM_API_URL=https://ppm.example.com \
-e TRUEPPM_API_TOKEN=tppm_your_token_here \
trueppm-mcp

Run it as a network service — publish the port and bind to 0.0.0.0 inside the container (the container boundary is the loopback equivalent here; still front it with a TLS-terminating proxy for anything beyond a private network):

Terminal window
docker run --rm -p 8000:8000 \
-e TRUEPPM_API_URL=https://ppm.example.com \
-e TRUEPPM_API_TOKEN=tppm_your_token_here \
trueppm-mcp --transport http --host 0.0.0.0 --port 8000

Pass the token via a Docker/Kubernetes secret or an env-file, never on the command line where it would land in shell history and the process table.

  • One enforcement point. Authorization is enforced by the API, identically for this server and the web client. The MCP process holds no privileged path and is not a second copy of the permission model. It can see nothing the token could not already read in the web client.
  • Read-only by scope, not just convention. The server defines only read tools and issues only GET requests, and an mcp:read token is rejected at every write path at the API layer. The two guarantees are independent — even a bug that added a write call would be refused by the token scope.
  • Owner-scoped, least-privilege, expiring tokens. The read surface accepts only a personal (owner-scoped) mcp:read token, so a leaked token reads exactly what its owner can read — never a whole project or program membership — and only until its required expiry. Project/program tokens are refused here entirely. Revoke a token from Personal Settings → API tokens the moment it is no longer needed; revocation takes effect immediately because every request re-checks the token.
  • No secret in logs. The token is never logged, never echoed in an error, and never included in a stack trace or a repr. Configuration errors name the missing variable, not its value.
  • Tool output is framed as untrusted data. Free-text fields (task descriptions, notes, risk mitigations, and the like) are wrapped in <untrusted-content> markers, and the server’s own instructions to the model state explicitly that everything a tool returns is project data, never a directive to follow — a mitigation for indirect prompt injection via user-authored text that any project Member with write access could set. This is a framing mitigation, not a content filter: it does not sanitize or reject text, and it cannot fully close the class server-side — a well-behaved client and model are still required to honor it.
  • Fail-closed boot. A token that does not authenticate fails startup with a clear message, so a misconfigured client never silently runs against the wrong instance.
  • Self-hosted. All traffic stays between your AI client, the server, and your own API. No third-party service is involved, and no plan or inference leaves your box.
  • Network exposure is opt-in. The default bind is loopback. Put any network transport behind a reverse proxy that terminates TLS; the server speaks plain HTTP and must never face the public internet directly.
  • Instance-wide kill switch. Setting TRUEPPM_MCP_ENABLED=false on the API denies every mcp:read token read across the whole instance (403), even for tokens that already exist, while leaving human session/JWT traffic on the same endpoints untouched. This is the “no agent access, period” operator lever; see Disabling MCP access entirely.
  • Team-level opt-out. A project (or program, or the workspace) can close itself to agent reads independently of the operator’s switch — see Team-level opt-out below.

The instance switch above is the operator’s lever. A team has its own, over reads of its own data: Project settings → Agents → Agent read access.

Project and program scope are three-state: Inherit (no opinion of its own), Allowed, or Blocked. The workspace root has no scope above it to inherit from, so it is two-state — Allowed or Blocked — and defaults to Allowed.

It blocks the read. It is not an after-the-fact log.

This distinction matters, because TruePPM ships both and they are different things:

SurfaceGuarantee
Agent read access (this control)Prevents the read. MCP tools return a refusal for the project’s data.
Agent oversightRecords reads that happened. Visibility, not prevention.

When a project is blocked, a project-scoped tool call returns 403 and every collection endpoint simply withholds that project’s rows — a cross-project tool like “my work” keeps working and returns the caller’s other projects, so one team’s decision never blanks another team’s data. Every refusal is recorded in the agent-action audit log as a policy refusal.

Only agent (MCP token) traffic. People are never affected — your team, and anyone signed in through the web or mobile app, read exactly what they always did. Blocking agent access does not hide anything from a human being.

Section titled “The rules that make it consent rather than a suggestion”
  1. Any scope may block; no scope may unblock another’s block. The effective answer is the AND of every scope — instance, workspace, program, project. A workspace administrator can close agent access org-wide, and cannot re-open a project that closed itself. There is deliberately no “enforce” or “lock” setting that would let a higher scope force agent reads back on.
  2. Inherit means “nobody above has objected” — never “yes, on your behalf”.
  3. An agent cannot lift its own restriction. mcp:read tokens are read-only at the API layer, so an agent cannot change this setting under any role.
  4. Changing it requires project Admin or above, and every change is recorded in the project’s history.

Why it is separate from the instance switch

Section titled “Why it is separate from the instance switch”

The two are shown as separate facts and never collapsed into one. A team’s page always reflects the team’s decision, so a toggle never reads “off” for a reason the team cannot act on. Because the switches are ANDed, turning one on can never override the other’s “off”.

Every read an MCP/agent token makes — and every refusal — is recorded as one append-only agent-action row: the acting token (its 8-character prefix only, never the secret), the human the token acts for, the operation, the project in scope, the verdict (allowed, or refused with an identity vs policy reason), a per-request payload hash, and the scheduler engine version at the time. This answers what did this agent read, when, and in which project — the question a single last_used_at timestamp could not.

The rows form a per-instance, hash-chained log: each row stores sha256(previous_hash ‖ the row's canonical fields), so altering or deleting any row breaks the chain. Verify the chain’s integrity at any time:

Terminal window
python manage.py audit_verify

It walks the chain in order, recomputes each hash, and reports the first break (or confirms the chain is intact). This is the OSS integrity self-check — it lets a team detect tampering on its own instance. External notarization, a cryptographic signature over the chain, enforced retention policy, legal hold, and an org-wide cross-instance trail are Enterprise (ADR-0112).

The log is append-only and grows without limit. To bound it, an operator can prune the oldest records with audit_prune, which deletes a block of the oldest rows and writes a checkpoint so audit_verify keeps verifying the records that remain. Pruning is manual and never automatic — TruePPM does not delete audit history on its own; cron the command yourself if you want periodic rotation.

Project members read their team’s agent actions at GET /api/v1/agent-actions/, scoped to the projects they belong to (plus their own agent’s actions). A human session read on the same views is not recorded — only token/agent traffic is.

SymptomLikely cause
Exit code 2, “Configuration error” on stderrTRUEPPM_API_URL or TRUEPPM_API_TOKEN is unset or blank.
Boot fails with “the TruePPM API rejected the configured token (HTTP 401)“The token is missing, malformed, or revoked. Mint a fresh mcp:read token.
A tool returns a 404 for a resource you expect to seeThe token’s role on its project/program does not permit reading that resource — 404 is the deliberate existence oracle, identical to the web client.
A tool that used to work now errors on a writeAn mcp:read token is refused at write paths by design; this server issues no writes, so this indicates a misrouted call, not a permission gap to widen.