Skip to content

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.

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.

#WhereMechanism
1IdP (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.
2Application (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.
3Wire (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.

  • 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%2Fadmin so it stays in its own path segment. Slashes, ?, #, %, control characters, and bool/non-string types are also refused.
  • Format-string injection through path_params values. {0:!r}-style content in a value is quoted before substitution.
  • Smuggled query parameters. Any query_params key 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. getScenario is denylisted because its spec mentions a “simulate answers” mode that interpolates recipients from sites and roles, with wording too ambiguous to trust. See _GET_DENYLIST in src/mcsinglewire/server.py.
  • Misuse of path_params keys. Unknown or missing keys are refused before path expansion.
  • OpenAPI spec corruption. spec.py raises on duplicate operationIds — the dispatch in api_call relies on uniqueness.

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 returns 401 for 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 /data volume, 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>/mcp can attempt to use the MCP tools. Deploy on a trusted network.
  • Margaret Hamilton-style review by the margaret-hamilton-reviewer agent on 2026-05-07. Verdict: “would not call api_call against production with the prior code” → three must-fixes (path quoting, getScenario denylist, 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). Plus tests/test_client.py covering the wire layer.
  • Empirical probes confirmed: httpx upper-cases methods before reaching the transport (case-bypass impossible); follow_redirects defaults to False (and even True would re-enter the transport); query values are percent-encoded (CRLF / header injection impossible); the bundled OpenAPI spec has no duplicate operationIds.

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).

FieldTypeNotes
eventstringAlways "api_call"
operation_idstringThe operationId the caller asked for
methodstring | null"GET" for completed/blocked, the method on rejection if known
pathstring | nullExpanded URL path (after _validate_call’s URL-quoting)
query_paramsobjectKeys always; values keys-only when SINGLEWIRE_AUDIT_REDACT_QUERY=1
statusint | stringUpstream HTTP status, or "rejected" / "write_blocked"
errorstring | nullError code (method_not_allowed, operation_denylisted, …)
duration_msintWall time from tool entry to record emission
bearer_fpstringSHA-256(token)[:16], or "anonymous"
mcp_client_idstring | nullThe MCP client_id from the JWT, distinguishes Claude Code instances

Retrieval (in the docker-compose deployment):

Terminal window
docker compose logs mcsinglewire | grep mcsinglewire.audit

Or with a JSON-aware filter:

Terminal window
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”
  1. Don’t panic — the architecture makes it very unlikely. But verify.
  2. Capture: docker compose logs mcsinglewire from the time in question. uvicorn logs every request with method and path.
  3. Cross-check Singlewire’s audit log in the admin console for any non-GET activity by the OAuth application’s client_id.
  4. 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.
  5. 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”
Tests covering all three enforcement layers
make check
Live confirmation that the OAuth endpoint is up
curl -sS https://<your-hostname>/.well-known/oauth-authorization-server

The 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
  • 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.

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_call or 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 httpx or fastmcp major versions — re-run the empirical probes in section 5; transport contracts are exactly the kind of thing that drifts silently across major versions.
  • Changing _ReadOnlyTransport for any reason — the wire-layer test in tests/test_client.py is 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.