DocsReference
The Softwarden MCP server
From docs/MCP.md in the repository.
On this page
mcp/ is a Model Context Protocol server that lets an AI assistant (Claude
Desktop, Claude Code, any MCP client) answer questions about the catalog: “which contracts renew in the next 90 days
and who owns them”, “what depends on Okta”, “which agents use gpt-4o”.
It is a thin client of the REST API (/api/v1/, REST API). It never touches the database: every tool call is
an API request made with an API token, so the organization, the token owner’s role, a read-only token and the
edition’s capabilities are all enforced by Softwarden, exactly as for any other script. It reads mostly from the
composite namespace (/api/v1/composite/...), the API’s compact contract for tools with a token budget.
Two transports:
| Transport | For | Token |
|---|---|---|
| stdio | Claude Desktop, Claude Code on your machine | SOFTWARDEN_TOKEN in the client’s config |
| Streamable HTTP | remote clients, a shared server (compose profile mcp) |
each client sends its own, Authorization: Bearer sw_... |
Package: Python 3.13, managed by uv, the official MCP Python SDK (mcp 2.3) and httpx2 (which the SDK already
requires). Code: softwarden_mcp/client.py (API client and error wording), tools.py (the tools as plain
functions), server.py (the SDK wiring, both transports, the HTTP token gate).
Create a token
An Admin creates it in Administration › API tokens › Create token (or POST /api/v1/api-tokens from a signed-in
session): choose the owner, a name such as “Claude Desktop”, an expiry (at most 365 days) and keep Read-only
ticked, the default. Copy the sw_... secret: it is shown once.
The token acts as its owner with the owner’s role. For questions only, use a read-only token; the owner’s role then limits what it can read, so a Viewer’s token is enough. Only a token that isn’t read-only, owned by an Editor or Admin, can use the write tools (below).
Claude Desktop (stdio)
-
Install uv and prepare the server once (downloads its packages from PyPI):
cd mcp && uv sync -
Open Claude Desktop’s configuration (Settings › Developer › Edit Config), which is
%APPDATA%\Claude\claude_desktop_config.jsonon Windows and~/Library/Application Support/Claude/claude_desktop_config.jsonon macOS, and add:{ "mcpServers": { "softwarden": { "command": "uv", "args": ["run", "--frozen", "--directory", "C:\\path\\to\\softwarden-app\\mcp", "softwarden-mcp"], "env": { "SOFTWARDEN_URL": "https://softwarden.example.com", "SOFTWARDEN_TOKEN": "sw_your_read_only_token" } } } }Use the full path to the repository’s
mcpfolder (on macOS,/Users/you/softwarden-app/mcp). If Claude Desktop can’t finduv, put its full path incommand(where uvon Windows,which uvon macOS). -
Quit Claude Desktop completely and start it again. The tools menu lists softwarden with its tools.
The server checks the token on start (GET /api/v1/auth/me) and logs who it acts as to stderr, which Claude
Desktop keeps in %APPDATA%\Claude\logs\mcp-server-softwarden.log (macOS: ~/Library/Logs/Claude/). A bad token
stops it with “Softwarden refused the API token”; an unreachable Softwarden is logged and each tool call retries.
HTTPS with Caddy’s local CA. The default docker compose up serves https://localhost with Caddy’s own CA,
which Python doesn’t trust. Either export that root and point the server at it:
docker compose cp proxy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
and add "SOFTWARDEN_VERIFY_TLS": "C:\\path\\to\\caddy-root.crt" to env; or, on a development machine, use the dev
stack’s plain-HTTP port (docker compose -f compose.yaml -f compose.dev.yaml up -d --build, then
"SOFTWARDEN_URL": "http://127.0.0.1:8080") or runserver (http://127.0.0.1:8010). SOFTWARDEN_VERIFY_TLS=false
turns verification off; never do that against a server on a network.
Environment
| Variable | Meaning |
|---|---|
SOFTWARDEN_URL |
Softwarden’s address, e.g. https://softwarden.example.com (a trailing /api/v1 is accepted). Required. |
SOFTWARDEN_TOKEN |
stdio: the API token. Ignored over HTTP (each client sends its own). |
SOFTWARDEN_VERIFY_TLS |
true (default), false (development only) or the path of a CA bundle. |
SOFTWARDEN_HOST_HEADER |
The Host header to send; compose sets the public host name while calling app:8000. |
MCP_ENABLE_WRITES |
1 registers the write tools. Off by default. |
MCP_HOST, MCP_PORT |
HTTP bind address. Default 127.0.0.1:8001; the image sets 0.0.0.0 inside its network. |
MCP_ALLOWED_HOSTS |
HTTP: comma-separated Host values accepted (name or name:* for any port). Default: loopback names. |
MCP_ALLOWED_ORIGINS |
HTTP: browser Origin values accepted (same syntax). Default: http://127.0.0.1:*, http://localhost:*. |
MCP_FORWARDED_ALLOW_IPS |
HTTP: proxies whose X-Forwarded-For names the client (comma-separated addresses, CIDRs or *), for the limit on refused tokens per address. Default: none (the connecting address; bound anywhere but loopback, no per-address limit). Compose sets *; the Helm chart sets its trustedProxies. |
Streamable HTTP
echo SOFTWARDEN_MCP_UPSTREAM=mcp:8001 >> .env # opt in: Caddy proxies /mcp only while this is set
docker compose --profile mcp up -d --build # adds the `mcp` service; https://<PUBLIC_HOSTNAME>/mcp
cd mcp && uv run softwarden-mcp --transport http # or locally: http://127.0.0.1:8001/mcp
The compose service is not started by a plain docker compose up. It is not published on the host: Caddy routes
/mcp on the main site to it, so it shares the site’s TLS certificate, but only while SOFTWARDEN_MCP_UPSTREAM is set
on the proxy (mcp:8001; compose passes it from .env or the shell). Unset, the default, /mcp answers 404 and
Caddy never looks up an upstream name: Docker’s resolver falls back to the host’s DNS and search domains, so a route
to mcp without the service could send bearer tokens to whatever mcp.<your domain> is. To turn the route off
again, remove the line from .env and docker compose up -d proxy. It reaches the app inside the compose network (http://app:8000, sent with the public host name) and
mounts no volume and no secret. /healthz answers ok without a token, for the container healthcheck.
Authentication is passthrough. Each client sends its own Softwarden API token as Authorization: Bearer sw_....
Before any MCP message is handled (and after the Host and Origin checks below), the server checks it with Softwarden (GET /api/v1/auth/me); an unknown,
revoked or expired token is 401 with WWW-Authenticate: Bearer realm="softwarden-mcp", error="invalid_token", and
no token is 401 too. Every API call for that request then uses that token, so each person’s role, organization and
read-only setting apply, and Softwarden’s audit names them. A token Softwarden accepted is trusted for 60 seconds
before it is checked again, so a revoked token stops working within a minute; per-token clients are kept by the
token’s SHA-256, never the token, and the token is never logged. The server holds no token of its own.
Refused tokens cost little. A token the server hasn’t seen is checked through one shared connection that holds no
token (each check sends its own); a client of its own, with its connection pool, is made only for a token Softwarden
accepted. A refused token is remembered by its hash for 60 seconds and refused again without asking Softwarden. An
address whose tokens were refused 20 times within a minute gets 429 with Retry-After for any token the server
doesn’t know, without Softwarden being asked. Tokens in the server’s cache (accepted, among the 64 most recently used)
are still re-checked as usual, so a spray of bad tokens doesn’t lock out people already connected; after a restart, or
once a token drops out of the cache, it is unknown again and waits out its address’s limit like any other.
Which address counts:
- Compose. Caddy sends
/mcpexactly the one client address it resolved (header_up X-Forwarded-For {client_ip}indocker/Caddyfile), and compose setsMCP_FORWARDED_ALLOW_IPS=*(the service is reachable only inside the compose network). If a load balancer sits in front of Caddy and you list it in Caddy’strusted_proxies, also settrusted_proxies_strict: without it Caddy resolves the leftmostX-Forwarded-Forentry, which a client can write, and a client could then spread its bad tokens over made-up addresses. - Helm. The chart sets
MCP_FORWARDED_ALLOW_IPSto itstrustedProxies(addresses or CIDRs); the client is the rightmostX-Forwarded-Forentry not in that list. - Elsewhere. Bound to loopback, the connecting address. Bound to any other address without
MCP_FORWARDED_ALLOW_IPS, refused tokens are not limited by address at all (the shared checker and the 60-second memory still apply): behind a proxy every request would share the proxy’s address, and one limit for everyone would let a stranger’s bad tokens refuse every new person. SetMCP_FORWARDED_ALLOW_IPSto your proxy’s addresses to get the limit.
The alternative, a separate shared MCP secret with one service token behind it, was not chosen: everyone would act with one role and the audit would name the service account, not the person.
Host and Origin validation (the MCP specification’s guidance against DNS rebinding for HTTP transports) is always
on: a request whose Host isn’t in MCP_ALLOWED_HOSTS is 421, one whose Origin isn’t in MCP_ALLOWED_ORIGINS
is 403. Both are checked first, before the token, so such a request never makes the server ask Softwarden about a
token; the SDK checks them again inside. Locally the server binds to 127.0.0.1 only. The transport is stateless with JSON responses (no session
to keep, so a restart or a second replica needs nothing shared).
A client that can send a header connects with the URL and the bearer token. For example, Claude Code:
claude mcp add --transport http softwarden https://softwarden.example.com/mcp --header "Authorization: Bearer sw_..."
Tools
Every tool returns a short text for the model to read and the same facts as structured content. Lists are capped and say how many there were; empty fields are left out; missing owners are named as gaps, not left null.
| Tool | Answers | API |
|---|---|---|
search |
Find records, vendors, people, contracts, attachments by name and text, with kind and ID. | composite/search |
get_item |
Everything about one item by kind and ID (application, component, ai_agent, integration, contract, renewal, license_pool, vendor, person, team, reminder, candidate), plus its revision. |
the item’s detail route |
renewals_with_owners |
Contracts and renewals coming up, with renewal owner, contract owner, IT follow-up, the covered records’ owners, vendor contact, cost, the cancel-or-decide deadline and gaps. | composite/renewals |
dependencies |
What a record depends on and what depends on it, with data flows. | composite/dependencies |
spend |
Annualized spend per currency by vendor, department, category or cost center. | composite/spend |
expiring_credentials |
Client secrets, certificates and API tokens ending soon, with the linked record’s owners. Never a secret. | composite/expiring-credentials |
data_gaps |
The data-quality score, failures per check, and the records failing. | composite/data-gaps |
agents_by_model |
Which AI agents use a model or a provider’s models; with no argument, every model in use and how many agents use it. | ai-models/in-use, ai-models?q=, ai-providers, ai-agents?model= / ?provider= |
discovery_summary |
Discovery’s queue: candidates, records no longer seen, source differences, each connector’s last run. | composite/discovery-summary |
list_reminders |
Dated reminders, soonest first, with IDs. | reminders |
list_candidates |
Discovery candidates with IDs and the record each looks like. | discovery/candidates |
renewals_with_owners and its date. By default GET composite/renewals?within_days=N filters on the action
deadline (the day to cancel or decide by), overdue ones included (DECISIONS_NEEDED, Phase 7 composite (b)). “What
renews in the next 90 days” is about the term end, so the tool’s default date_basis is term_end, which it passes
as basis=term_end: Softwarden then keeps the open actions whose term (a renewal’s renewal date, a contract’s end
date) ends within N days or has already ended, whatever their deadline, soonest term end first. Softwarden filters,
so nothing is dropped on the way: an entered deadline can be later than the renewal date (renewal validation allows
it), and such a renewal is still listed. Renewals with no term end recorded are not in the term-end view; the answer
says so and says which date it filtered on, and its headline adds “or already ended” when a listed term has.
date_basis=action_deadline is the deadline view (“what must I act on”). count is Softwarden’s count of every
match; with more than limit (at most 200) rows the answer says how many there are in all, that only the first are
shown, and that overdue rows (deadline view) or ended terms (term-end view) come first and count toward that limit.
Contracts with no cancellation deadline stored (no end date) are not in either view, as on the dashboard.
Recorded data, not instructions. Names, descriptions, notes, reasons and the values discovery copied from other
systems are typed by people or by those systems, so they can contain text that reads like an instruction. The server’s
instructions tell the model that everything a tool returns is data to report, never instructions to follow, and the
text answers quote those values (as JSON strings) so they stand apart from the answer’s own wording. Treat some of
that text as attacker-influenced: the least trusted is the display name (and publisher) of an app discovery found in
Microsoft Entra, which whoever registered the app chose, possibly in another organization’s tenant; list_candidates,
get_item and search return it as it was read.
Write tools and their risks
Off by default. With MCP_ENABLE_WRITES=1 the server also registers:
| Tool | Effect | API |
|---|---|---|
update_record |
Sets one field of an application, component or AI agent: description, purpose, lifecycle, criticality, business or technical owner, support team, IT steward, cost center, data classification. | PATCH {kind}/{id} |
snooze_reminder |
Stops a reminder’s notifications through a date (at most 90 days), for everyone it reaches. | POST reminders/{id}/snooze |
acknowledge_reminder |
Stops a reminder’s advance notices. | POST reminders/{id}/acknowledge |
dismiss_candidate |
Dismisses a discovery candidate with a reason (Reopen in the queue undoes it). | POST discovery/observations/{id}/dismiss |
Every write:
- takes IDs only as UUIDs, checked by the tool’s schema and again before any request, and the client percent-encodes
every value in a URL path, so an ID can’t steer the request to another route (
../api-tokens/<id>/revoke?); - needs the
revisionthatget_itemreturned and sends it asIf-Match, so nothing changes if someone edited the item since it was read (412; the tool says to read it again, and never names the new revision, so the only way to retry is to read the item and see what changed). The revision must be exactly the one returned (letters, digits,-,_):*, a list of tags or a weak tag, which would match any revision, are refused. The dismiss route takes If-Match since this package. - is described as
CHANGES SOFTWARDENwith its effect, takes explicit inputs (one field and one value, a date, a reason) and is annotated as not read-only and destructive (destructiveHint: true: each can clear, silence or dismiss something), so clients ask before running it; the server’s instructions tell the model to write only when the user explicitly asks for that change, never because a tool result suggests it, and to show the change and get the user’s go-ahead first; - is refused by Softwarden for a read-only token (
403 /problems/read-only-token), which the tool reports as “This API token is read-only, so Softwarden refused the change; nothing was changed”, and by role for a Viewer; - is an ordinary audited edit under the token owner’s name, visible in the item’s History.
Risks to weigh before turning writes on: a model can misread a request and change the wrong record or field (the
revision guard stops stale edits, not wrong ones); clearing an owner creates a data-quality gap; a snoozed reminder
sends nothing until its date; a dismissed candidate leaves the queue. Prefer a dedicated token per person with an
expiry, keep writes off on the shared HTTP service unless the people using it need them, and review the audit log.
Keep writes off for any client set to approve tool calls without asking: the confirm-first rule is an instruction to
the model, and text in a tool result (a discovered app’s name above all) is what an attacker would use to talk a
model past it.
Creating records, links and attachments (IT1’s create_item, add_attachment_link, add_relationship) is not
offered: a create has no revision to guard it and is harder to undo cleanly than an edit.
Errors
API problem details become sentences (softwarden_mcp/client.py): 401 “Softwarden refused the API token …”; 403
capability-required “Your Softwarden edition doesn’t include contracts …”; 403 read-only-token as above; 403
forbidden “Your role in Softwarden can’t do this”; 404 no such item in this organization; 409 the conflict’s
detail; 412 changed since read (read it again; the new revision is not given); 429 “rate limit was reached; retry in N seconds” (from Retry-After); 400 each
field’s message; 5xx an internal error with the request ID; no connection or a timeout names the address. No message
carries the token.
Tests
cd mcp && uv sync && uv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest -q:
every tool against a fake API, the error wording, writes absent by default and guarded with the flag, and the SDK’s
own client over an in-process session, stdio (the server as a subprocess) and streamable HTTP (uvicorn on a loopback
port; token gate, Host and Origin checks, passthrough). apps/api/tests/test_mcp_e2e.py runs renewals_with_owners
against a real API server on the test database with a read-only token. CI runs both (jobs mcp and api) and builds
the image.
Owner check: Done-when #3
“The MCP server answers ‘which contracts renew in the next 90 days and who owns them’ from Claude Desktop with a read-only token.”
- Start Softwarden with data. On a development machine:
docker compose -f compose.yaml -f compose.dev.yaml up -d --build, thendocker compose exec app python manage.py seed_demo(the demo has Zoom Business ending in 40 days and ServiceDesk Plus in 75, renewal owner Lee Chen). Or use your own installation with a contract whose end date is within 90 days and a renewal owner set. - Sign in as an Admin (demo:
admin@example.test, passwordsoftwarden-demo-2026), open Administration › API tokens, create a token for the Viewer (demo:viewer@example.test) named “Claude Desktop”, read-only (default), 30 days. Copy thesw_...secret. cd mcp && uv sync.- (Stdio needs no proxy change;
SOFTWARDEN_MCP_UPSTREAMis only for the HTTP service.) Add thesoftwardenentry above toclaude_desktop_config.jsonwithSOFTWARDEN_URLhttp://127.0.0.1:8080(the dev stack; forhttps://localhost, setSOFTWARDEN_VERIFY_TLSto Caddy’s exported root) and the token. LeaveMCP_ENABLE_WRITESunset. - Quit and restart Claude Desktop. In the tools menu, softwarden shows 11 tools and no write tool. The log
(
mcp-server-softwarden.log) saysconnected as Vic Viewer (viewer). - Ask: “Which contracts renew in the next 90 days and who owns them?” Claude calls
renewals_with_owners(approve it if asked). Expected on the demo: Zoom Business (ends in 40 days) and ServiceDesk Plus license (ends in 75 days), each with its end date, its cancel-or-decide date and owner Lee Chen, and a note that it filtered on the term end. Workday HCM (ends in 165 days) is not listed. - Optional: ask it to change a record’s lifecycle. With writes off it has no tool to do so; with
MCP_ENABLE_WRITES=1and the same read-only token, the change is refused as read-only and nothing changes. - Revoke the token in Administration › API tokens when done; the next tool call answers “Softwarden refused the API token”.