Metadata-Version: 2.5
Name: modal-mcp-op
Version: 0.1.0
Summary: Read-only MCP server for observing and debugging Modal applications
License: Apache-2.0
License-File: LICENSE
Keywords: debugging,mcp,modal,observability,read-only
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.10
Requires-Dist: mcp<3,>=2
Requires-Dist: modal<2,>=1.5
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: tiktoken>=0.7; extra == 'dev'
Description-Content-Type: text/markdown

# modal-mcp

A read-only MCP server that exposes [Modal](https://modal.com) 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](#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](#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

```bash
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:

```bash
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:

```json
{
  "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:

```bash
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 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

```jsonc
{
  "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`](src/modal_observe/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.](https://modal.com/docs/guide/endpoint-metrics) |
| **Audit logs** ("who changed what") | [Enterprise plan, dashboard-only.](https://modal.com/docs/guide/audit-logs) 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

```bash
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.
