Safety model
This page is the authoritative reference for what mcsinglewire can and cannot do. It’s written for auditors and deployment reviewers — read it before letting the server in front of a Singlewire production tenant.
1. What this document promises
Section titled “1. What this document promises”mcsinglewire does not, and cannot, mutate state on the Singlewire InformaCast Mobile API. Every code path that reaches the upstream API is GET-only. No tool exposes POST, PUT, PATCH, DELETE, or any other write method.
This isn’t a convention. It’s enforced by three independent layers, each of which would have to be defeated for a write to happen.
2. The three layers
Section titled “2. The three layers”| # | Where | Mechanism |
|---|---|---|
| 1 | IdP (Singlewire) | The OAuth application registered in Admin > Integrations > Applications has only urn:singlewire:*:read scopes selected. No :write, :manage, or :admin. The bearer Singlewire issues to mcsinglewire is, at the protocol level, incapable of authorizing a write. |
| 2 | Application (mcsinglewire) | _validate_call() runs every request through five gates before any I/O: known operationId, GET method, not on the static denylist, no undeclared query_params, no unsafe path_params. Path values are URL-quoted with safe="" so traversal, slashes, and query-string injection are impossible. |
| 3 | Wire (httpx transport) | _ReadOnlyTransport.handle_async_request raises WriteAttemptError on any non-GET request, regardless of how the caller constructed it. This is the lowest layer; nothing reaches the network without going through it. |
Defence in depth means: layer 1 fails → layer 2 catches it; layer 2 fails → layer 3 catches it. All three would have to be wrong for a write to reach Singlewire.
3. What we explicitly protect against
Section titled “3. What we explicitly protect against”- Non-GET operationIds.
api_call("createMessageTemplate", ...)returns{"error": "method_not_allowed"}and never instantiates an HTTP request. - Path traversal.
path_params={"alarmId": "../../admin"}is rejected — the value is URL-quoted to%2E%2E%2F%2E%2E%2Fadminso it stays in its own path segment. Slashes,?,#,%, control characters, and bool/non-string types are also refused. - Format-string injection through
path_paramsvalues.{0:!r}-style content in a value is quoted before substitution. - Smuggled query parameters. Any
query_paramskey not declared for the operation in the OpenAPI spec is rejected. Future side-effecting query parameters added by Singlewire are not silently reachable. - GETs with ambiguous side effects.
getScenariois denylisted because its spec mentions a “simulate answers” mode that interpolates recipients from sites and roles, with wording too ambiguous to trust. See_GET_DENYLISTinsrc/mcsinglewire/server.py. - Misuse of
path_paramskeys. Unknown or missing keys are refused before path expansion. - OpenAPI spec corruption.
spec.pyraises on duplicate operationIds — the dispatch inapi_callrelies on uniqueness.
4. What this document does NOT promise
Section titled “4. What this document does NOT promise”These are real, named limitations. Read them.
- Read access leaks. Read-only doesn’t mean private. A user with a valid Singlewire bearer can read whatever Singlewire authorizes them to read, through this server, just as they could through the Singlewire UI. Audit access controls in Singlewire itself, not here.
- Trust at the proxy boundary. mcsinglewire uses
DebugTokenVerifier, which accepts any non-empty bearer at the proxy. Validation is delegated to the upstream API (which returns401for invalid tokens). A Singlewire bearer issued for a different application could be presented to this proxy and forwarded; the read-only transport still refuses writes, but read access is what that token’s scopes authorize. This is a deliberate trust model. - No GET denylist completeness guarantee. The denylist contains
one entry today (
getScenario). If Singlewire adds new GETs whose semantics are state-changing, those will be reachable until the denylist is updated. Re-audit on every spec refresh. - Operating-system-level access. The container runs as a
non-root user (
mcsw, uid 1000) and writes only to a mounted/datavolume, but anyone with shell access on the host can do whatever the host permits. Network access controls are the user’s responsibility. - No MCP-transport authentication. The MCP server’s HTTP
listener is unauthenticated as an MCP transport. OAuth Proxy
guards the authentication flow for upstream tokens, but anyone
who can reach
https://<your-hostname>/mcpcan attempt to use the MCP tools. Deploy on a trusted network.
5. How we verified this
Section titled “5. How we verified this”- Margaret Hamilton-style review by the
margaret-hamilton-revieweragent on 2026-05-07. Verdict: “would not callapi_callagainst production with the prior code” → three must-fixes (path quoting,getScenariodenylist, query-param validation) — all addressed. Verdict after fix: clear to call. - Unit tests in
tests/test_server.py: cases covering the validation surface (path traversal, slash, query-injection, control char, unknown key, missing key, non-string type, bool, undeclared query, denylist resolution, every non-GET method). Plustests/test_client.pycovering the wire layer. - Empirical probes confirmed: httpx upper-cases methods before
reaching the transport (case-bypass impossible);
follow_redirectsdefaults toFalse(and evenTruewould re-enter the transport); query values are percent-encoded (CRLF / header injection impossible); the bundled OpenAPI spec has no duplicate operationIds.
6. Audit trail
Section titled “6. Audit trail”Every api_call invocation emits a structured JSON record on the
mcsinglewire.audit logger at INFO. The record covers all three
exit paths (validation rejection, write-blocked, completed upstream
call) and intentionally never carries the bearer token — only a
SHA-256 fingerprint (bearer_fp, first 16 hex chars).
| Field | Type | Notes |
|---|---|---|
event | string | Always "api_call" |
operation_id | string | The operationId the caller asked for |
method | string | null | "GET" for completed/blocked, the method on rejection if known |
path | string | null | Expanded URL path (after _validate_call’s URL-quoting) |
query_params | object | Keys always; values keys-only when SINGLEWIRE_AUDIT_REDACT_QUERY=1 |
status | int | string | Upstream HTTP status, or "rejected" / "write_blocked" |
error | string | null | Error code (method_not_allowed, operation_denylisted, …) |
duration_ms | int | Wall time from tool entry to record emission |
bearer_fp | string | SHA-256(token)[:16], or "anonymous" |
mcp_client_id | string | null | The MCP client_id from the JWT, distinguishes Claude Code instances |
Retrieval (in the docker-compose deployment):
docker compose logs mcsinglewire | grep mcsinglewire.auditOr with a JSON-aware filter:
docker compose logs mcsinglewire \ | awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \ | jq .See also Read the audit log for worked examples of common compliance queries, and The audit log as evidence for what the log proves vs. doesn’t.
7. What to do if you suspect a write happened
Section titled “7. What to do if you suspect a write happened”- Don’t panic — the architecture makes it very unlikely. But verify.
- Capture:
docker compose logs mcsinglewirefrom the time in question. uvicorn logs every request with method and path. - Cross-check Singlewire’s audit log in the admin console for any non-GET activity by the OAuth application’s client_id.
- If the methods don’t match (i.e., uvicorn logged GET but Singlewire logged a write), preserve the logs and treat it as an active incident — the architecture has been bypassed in some way that requires forensic inspection.
- Add tests for the failure mode you found before resuming use.
8. How to re-verify the guarantee yourself
Section titled “8. How to re-verify the guarantee yourself”make checkcurl -sS https://<your-hostname>/.well-known/oauth-authorization-serverThe server’s health() tool also reports denylisted_operations.
Or in an authenticated MCP session:
health() → "denylisted_operations": ["getScenario"]
api_call("getScenario", path_params={"scenarioId": "anything"}) → {"error": "operation_denylisted", ...}
api_call("createMessageTemplate", ...) → {"error": "method_not_allowed", ...}
api_call("getAlarm", path_params={"alarmId": "../../admin"}) → {"error": "invalid_path_params", "message": "...control characters..." or quoted}9. Where the audit-relevant material lives
Section titled “9. Where the audit-relevant material lives”Directorysrc/mcsinglewire/
- server.py read-only enforcement,
_GET_DENYLIST - client.py
_ReadOnlyTransport(wire-layer guardrail) - spec.py OpenAPI loader + dispatch
- audit.py structured audit logger
Directorydata/
- openapi.json bundled spec snapshot; refreshed via
make refresh-spec
- openapi.json bundled spec snapshot; refreshed via
- server.py read-only enforcement,
Directorytests/
- test_server.py validation surface (path traversal, denylist, …)
- test_client.py transport-layer guardrail
- test_spec.py spec parsing & search
- test_config.py env-var loading, signing key
- test_audit.py record shape, bearer non-leak
- test_prompts.py prompt surface & read-only intent
The architecture and threat model live on this page. Everything else is in the tree above.
10. Re-audit triggers
Section titled “10. Re-audit triggers”Any of these should trigger a fresh review against this document:
- Refreshing the bundled OpenAPI spec (
make refresh-spec) — scan new GETs for state-changing semantics; update the denylist if needed. See Refresh the OpenAPI spec. - Adding any new MCP tool that talks to Singlewire — must use
_validate_callor an equivalent gate. - Changing the OAuth application’s scopes in Singlewire — re-screenshot the scopes list and update layer 1’s claim. See OAuth scopes.
- Bumping
httpxorfastmcpmajor versions — re-run the empirical probes in section 5; transport contracts are exactly the kind of thing that drifts silently across major versions. - Changing
_ReadOnlyTransportfor any reason — the wire-layer test intests/test_client.pyis the canonical guardrail; never weaken it without a written justification recorded here.
For the workflow on a denylist edit specifically, see Add an operation to the GET denylist.