Skip to main content

modal-mcp

A read-only MCP server that exposes Modal to LLM agents for debugging and observing deployed applications: logs, app and function state, container lifecycle, failures, deployment history, and cost.

Read-only by construction, permanently. No tool in this server can create, change, stop, restart or delete anything in a Modal workspace. That is not a flag, a toolset toggle, or a refusal at the edge — the mutating RPCs are unreachable as attributes on the object the tool layer holds. See The read-only boundary.

Status: alpha. Every tool has been exercised against a real Modal workspace; the gaps are listed honestly in What this cannot see.

Published to PyPI as modal-mcp-op. The obvious name modal-mcp was already taken by an unrelated project with similar positioning.

Install and run

uvx modal-mcp-op stdio                # or: uv tool install modal-mcp-op
export MODAL_TOKEN_ID=...
export MODAL_TOKEN_SECRET=...
modal-observe stdio

Credentials come from the process environment only. The server does not read a .env file; .env.example is a template you source yourself:

cp .env.example .env && set -a && . .env && set +a

There is no interactive login, no linked-project state, and no config file — so this runs headless in a container or a CI job.

MCP client configuration:

{
  "mcpServers": {
    "modal": {
      "command": "modal-observe",
      "args": ["stdio"],
      "env": { "MODAL_TOKEN_ID": "...", "MODAL_TOKEN_SECRET": "..." }
    }
  }
}

Use a service-user token with a Viewer role rather than a personal user token. This server only ever reads, so it needs nothing more, and a user token inherits a human's full workspace access. modal_status reports which kind it holds so a mismatch is visible.

Driving it without an MCP client

Everything is reachable from a shell, because that is the only way to debug it during an incident:

modal-observe tools                    # tools/list as JSON - needs NO credentials
modal-observe doctor                   # resolve scope; prints no secret value
modal-observe call modal_status '{}'
modal-observe call modal_logs '{"app_id":"ap-...","since":"24h"}'
python scripts/measure-schema.py       # tool-schema token cost vs the baseline

The tools

Tool Use it for
modal_status Entry point. Workspace, token kind, environments with live concurrency and spend saturation, apps with state and last deploy including git commit. Takes no required arguments.
modal_app One app in depth: functions and ids, deployment history, live queue depth and concurrency per function.
modal_logs Logs. mode=histogram (default) locates the spike cheaply; mode=lines reads it.
modal_containers Container lifecycle, and the exit code, exception and traceback for a failed container.
modal_sandboxes Sandbox inventory, outcome, and cumulative CPU/memory/GPU resource-seconds.
modal_resources Volumes, secrets, queues, dicts, domains, proxies — metadata only.
modal_cost Metered and billed spend with category breakdown.
modal_capabilities What is reachable and what is not, and why. Needs no credentials.

The incident path

"The pdf-extractor app started failing this morning."

  1. modal_status() — the app is deployed, 0 containers, v12 deployed 06:12 by alex from commit a1b2c3d on main.
  2. modal_logs(app_id, since="24h") — histogram: stderr flat until 06:00, then spiking. About 4 KB for the whole day.
  3. modal_logs(app_id, mode="lines", since="2026-03-04T06:00:00Z", until="2026-03-04T07:00:00Z", source="stderr") — the error text, each line carrying its task_id and function_id.
  4. modal_containers(task_id=..., detail="full") — exit code, exception, traceback.
  5. modal_app(app_id, sections=["deployments","stats"]) — the previous good version to diff against, and whether work is backing up.

Five calls, four of them cheap.

Bounding log volume without hiding the bug

mode=histogram returns per-interval stdout / stderr / system counts separately for the whole window. Measured on a real workspace over one 30-day window, comparing the two serialised JSON envelopes an agent actually pays for: the histogram at the default bucket width cost 4,729 bytes, where fetching the same window's lines cost 23,520 bytes5x. Ask the histogram when, then ask for lines only there.

Every result also states what was cut. A result is never quietly shortened: losses[] names the cut and carries a cursor when it can be continued.

Every result is a typed envelope

{
  "data": ...,
  "coverage": {
    "state": "complete",       // see below
    "requested": {...},        // what you asked for
    "effective": {...},        // what Modal actually served
    "counts":   {...},
    "sources":  [{ "name": "AppFetchLogs", "state": "read" }]
  },
  "losses":     [{ "kind": "row_cap", "reason": "...", "cursor": "..." }],
  "provenance": { "workspace": "...", "calls": [...], "client_version": "modal 1.5.4" }
}

coverage.state is an enum, and the distinctions are load-bearing:

State Means
complete everything matching the query was returned
bounded a limit was applied and may have hidden more
truncated the server cut the result; losses carries a cursor
clamped Modal narrowed the query — compare requested with effective
empty_confirmed the query ran and the answer is genuinely zero
not_queried you did not ask for this section
upstream_unavailable the query failed — this is not the same as zero
not_exposed_by_platform Modal does not expose this to any API client

The last two exist because of something observed live: the same log query returned ResourceExhaustedError, then zero rows, then real data, within one session. A server that reports all three as "no logs found" will let an agent close an incident because the log backend was briefly busy.

Errors are structured too — {code, retryable, remedy} — so an agent does not have to parse English to decide whether to retry.

The read-only boundary

Modal's gRPC service has 239 methods. 98 match a naive "read" prefix. Ten of those mutate, and two more are worse because the name gives no hint:

  • every *GetOrCreate creates — including SecretGetOrCreate
  • QueueGet pops values off the queue
  • FunctionGetOutputs carries clear_on_success and acks outputs

So the boundary is not a naming rule, a --read-only flag, or an MCP readOnlyHint annotation — any of those would have let at least one through. It is an explicit, reviewed frozen set of exact method names in allowlist.py; anything else raises on attribute access, before a request object is built. tests/test_readonly_boundary.py reflects over the live generated stub, so a Modal upgrade that adds RPCs fails the build rather than silently widening what this server can reach.

Secrets

modal_resources returns a secret's name, creator, creation time and last-used time. It cannot return values, and that is a property of Modal rather than a redaction step here: the key→value map (env_dict) appears on exactly two messages in Modal's entire API, both request types belonging to write RPCs. No response anywhere carries a secret value. tests/test_no_secret_path.py re-derives this from the installed protobuf definitions on every run.

Modal also exposes no key names for an existing secret, so a "did prod and staging differ?" fingerprint is not implementable. This server does not pretend otherwise.

Volume file listings are available; volume file contents are not — reading arbitrary application files into a model transcript is the same class of leak as a secret-reveal flag.

What this cannot see

Call modal_capabilities for the machine-readable version. The headlines:

Signal Status
CPU / memory / GPU utilisation time series Dashboard-only. Not exposed to any Modal API client. The API carries configured resources and, for Sandboxes only, cumulative resource-seconds.
Endpoint latency / throughput Dashboard-only.
Audit logs ("who changed what") Enterprise plan, dashboard-only. No audit RPC exists.
Per-call input breakdown FunctionCallList exists in the API and Modal disables it for API clients.
A deployed function's cron schedule and resource config Not readable; they exist only on create-side messages.
Log retention Roughly 30 days, measured — Modal publishes no retention figure. Longer requests are silently narrowed, and reported as clamped.

These are listed so an agent does not infer health from silence.

Development

uv venv && uv pip install -e '.[dev]'
python -m pytest              # no credentials needed
ruff check . && ruff format --check .

tests/test_mcp_server.py spawns modal-observe stdio and drives it with a real MCP client — initialize, tools/list, tools/call — because build_server() is the one thing calling list_tools_payload() and dispatch() directly cannot exercise.

Test coverage is opt-out with a written justification, never opt-in by omission: a tool registering no check fails tests/test_tool_coverage.py. Fixtures under tests/fixtures/ are real Modal responses, scrubbed — every id, name, url and log line replaced. Two are hand-authored and labelled, for states the workspace could not supply (a failed container, an empty sandbox list).

The tool-schema budget is an SLO, not a cliff: the suite reports the cost on every run and fails only above a ceiling justified against a real baseline. Deleting a signal to defend a round number is explicitly the wrong response to a budget warning.

Licence

Apache-2.0.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

modal_mcp_op-0.1.0.tar.gz (262.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

modal_mcp_op-0.1.0-py3-none-any.whl (63.7 kB view details)

Uploaded Python 3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page