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."
modal_status()— the app is deployed, 0 containers, v12 deployed 06:12 byalexfrom commita1b2c3donmain.modal_logs(app_id, since="24h")— histogram: stderr flat until 06:00, then spiking. About 4 KB for the whole day.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 itstask_idandfunction_id.modal_containers(task_id=..., detail="full")— exit code, exception, traceback.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 bytes —
5x. 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
*GetOrCreatecreates — includingSecretGetOrCreate QueueGetpops values off the queueFunctionGetOutputscarriesclear_on_successand 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file modal_mcp_op-0.1.0.tar.gz.
File metadata
- Download URL: modal_mcp_op-0.1.0.tar.gz
- Upload date:
- Size: 262.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
91c1b98fa66eb8338cf4e8e2edffe5c64d31009e51fd45a44ad009f880b60ae5
|
|
| MD5 |
7497e259e08cf61227c1938b294b3bf2
|
|
| BLAKE2b-256 |
1a83c4104b5f137fb2ed4701c594c0807f015249dcf3f5725b92ed3b4c413f4c
|
Provenance
The following attestation bundles were made for modal_mcp_op-0.1.0.tar.gz:
Publisher:
publish.yml on jbrocksfellas/modal-mcp-op
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modal_mcp_op-0.1.0.tar.gz -
Subject digest:
91c1b98fa66eb8338cf4e8e2edffe5c64d31009e51fd45a44ad009f880b60ae5 - Sigstore transparency entry: 2498649764
- Sigstore integration time:
-
Permalink:
jbrocksfellas/modal-mcp-op@c9bfc2ea61477db10eebcd945574aefeabb56a01 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jbrocksfellas
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c9bfc2ea61477db10eebcd945574aefeabb56a01 -
Trigger Event:
push
-
Statement type:
File details
Details for the file modal_mcp_op-0.1.0-py3-none-any.whl.
File metadata
- Download URL: modal_mcp_op-0.1.0-py3-none-any.whl
- Upload date:
- Size: 63.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4763d4cccafd2d45b0543701c444fdd3703576a450590b4d3fc83158fac679c9
|
|
| MD5 |
153323f5e40ef0f4d8813085c6ea2fe1
|
|
| BLAKE2b-256 |
dbf64f7a605e602df19b6a62eab604d38b3c7c57b2a6e791f9c2356d75085c71
|
Provenance
The following attestation bundles were made for modal_mcp_op-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on jbrocksfellas/modal-mcp-op
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
modal_mcp_op-0.1.0-py3-none-any.whl -
Subject digest:
4763d4cccafd2d45b0543701c444fdd3703576a450590b4d3fc83158fac679c9 - Sigstore transparency entry: 2498649768
- Sigstore integration time:
-
Permalink:
jbrocksfellas/modal-mcp-op@c9bfc2ea61477db10eebcd945574aefeabb56a01 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jbrocksfellas
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c9bfc2ea61477db10eebcd945574aefeabb56a01 -
Trigger Event:
push
-
Statement type: