diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 01c45033..2d6a3048 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -55,3 +55,5 @@ jobs: - name: Erlang Common Test suites run: ./rebar3 ct + - name: Erlang Common Test suites (MCP inspector) + run: ./rebar3 ct --dir apps/erlangbridge/test/mcp diff --git a/.vscode/launch.json b/.vscode/launch.json index 1296b590..73238995 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -47,6 +47,22 @@ "outFiles": ["${workspaceFolder}/out/**/*.js"], "preLaunchTask": "npm: compile" }, + { + // Only the extension under development + what is installed in the dedicated + // extensions dir (your normal extensions are not loaded). Install Claude Code there once: + // code --extensions-dir ~/.vscode-erlang-dev/extensions --install-extension anthropic.claude-code + "name": "Launch Extension (sample-mcp, Claude only)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "${workspaceFolder}/../../erlang/sample-mcp", + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/out"], + "preLaunchTask": "npm: compile" + }, { "name": "Launch Tests", "type": "extensionHost", diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0e154f..1344cccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Change log + +## Version 1.2.6 (October 1, 2026) + +* MCP topology inspector for debug sessions (opt-in) + - New settings `erlang.mcp.enabled` (default `false`), `erlang.mcp.host` (loopback only) and `erlang.mcp.port` (`0` = any free port). When enabled, a debug session (launch or attach, not *Run without debugging*) starts a read-only, token-authenticated [MCP](https://modelcontextprotocol.io) server inside the debugged Erlang node, so that an AI agent can map its OTP topology: `runtime_summary`, `application_overview`, `supervision_tree`, `registered_processes`, `process_info`, `top_processes` (hottest processes by queue length, reductions or memory), `ets_tables` (approved tables, metadata only) and `debug_session`. Results are bounded, paginated, with typed edges, evidence, confidence and explicit coverage limitations + - VS Code registers the live endpoint automatically (MCP server list) and **Erlang MCP: Show Connection Details** helps configure other clients. The session token never appears in logs, DAP events or settings + - An optional top-level `{mcp, [...]}` term in the project's `rebar.config` restricts tools, ETS tables and limits. Nothing changes when the setting is off. See [HELP.MD](./HELP.MD#mcp-topology-inspector-ai-agents-debug-sessions-only) and [mcp_security.md](./mcp_security.md) + - Agent-sized answers: graph tools default to `detail: "summary"` (compact entities and edges, 100 items per page, modules listed per application only); `detail: "full"` returns the complete JSON (e.g. to save it to a file), `pageSize` tunes the pages + - **Output > Erlang MCP** journals every MCP request (tool, status, inspector time, size, pages, delay since the previous request) and summarizes the session, to see how an agent uses the inspector; arguments, results and token are never written + - Optional user setting `erlang.mcp.authToken` (development convenience): a fixed token so that a static client such as Claude Code (`claude mcp add`) is configured once; default empty = random token per session + - The verbose debug log no longer prints the Erlang cookie passed to an attach helper + - Supervisors in `supervision_tree` carry their child counts (`children`: specs, active, supervisors, workers); `debug_session` lists the processes stopped at a breakpoint (`pausedProcesses`), `process_info` adds a `debugger` field for such a process, and a supervisor that is itself stopped at a breakpoint is reported with the `unavailable_while_paused` omission instead of a plain timeout + - `runtime_summary` also reports the run queue length and process/atom/port/ETS counts against their VM limits (`resources`) + - Graph tools accept `format: "mermaid"`: the page is also returned as a ready-to-render Mermaid `graph TD`, built from the reported edges + - New tool `topology_overview`: the application, its supervision tree, registered processes and owned approved ETS tables of one application in a single call (each part still subject to the project policy) + - New tool `changes_since`: compares a retained collection with a fresh observation and reports added, removed and replaced (restarted) entities; removals from partial collections are marked unconfirmed + - New tools `top_ports` (local ports by queue size or traffic counters: driver name and owner only, never addresses or command lines) and `ets_summary` (ETS memory per owner process, no table names or contents); `runtime_summary` lists the connected nodes (host redacted by default); new edge type `owns_port` + - Developer tier (opt-in, named in the project's `allowed_tools`): `process_state` (bounded `sys:get_state` of a gen_server/gen_statem/gen_event, never a supervisor), `mailbox_sample` (oldest messages) and `ets_sample` (a few rows of an approved table), with best-effort redaction of secret-like keys and URL credentials; `allowed_ets_tables` accepts `all`; limits can be raised above their defaults up to higher ceilings (e.g. `collection_ttl_ms` up to 10 min) + - Hardening: the bearer token and cursor secret no longer appear in crash reports, non-ASCII workspace paths find the project policy, `ets_summary` only counts approved tables, `top_ports` only names well-known drivers, inspector processes are excluded from the rankings, child ids are redacted + - `allowed_tools` accepts `default` (the built-in set, also after upgrades) or `[default, <<"tool">>]`; the server tells the agent in its `instructions` which tools the project policy disables + - New tool `process_groups`: all local processes aggregated by initial call (count, memory, reductions, queue, registered vs unregistered, sample ids) in an answer of fixed size, to find fan-out and leaks of anonymous processes + - MCP prompt `map_application`: the sample prompt served by the inspector itself (argument-checked, grants nothing) + - A ready-to-use prompt to map an application with an agent: [HELP.MD](./HELP.MD#mcp-topology-inspector-ai-agents-debug-sessions-only) (*Sample prompt*) + +* Update erlang.plist to latest + +--- + ## Version 1.2.5 (September 27, 2026) * [358](https://github.com/pgourlain/vscode_erlang/issues/358) : Launch (Run / Debug) broken on Linux and macOS since 1.2.4 diff --git a/DevelopersReadme.md b/DevelopersReadme.md index aec01725..b7861a6d 100644 --- a/DevelopersReadme.md +++ b/DevelopersReadme.md @@ -26,8 +26,15 @@ then ```bash ./rebar3 ct +./rebar3 ct --dir apps/erlangbridge/test/mcp # MCP inspector suites (policy, server, debugger bridge) +npm test # includes out/test/test-suite/mcp/* (real erl target) ``` +The embedded MCP inspector lives in `apps/erlangbridge/src/mcp/` (`mcp_*.erl`, all Erlang) and is +only compiled to `_build/default/lib/ebin` by the debug adapter and started on request +(`vscode_connection`: `mcp_start`, `mcp_renew`, `mcp_stop`); the LSP node never loads it. Its +tests/fixtures are in `apps/erlangbridge/test/mcp/` and `test/test-suite/mcp/`. + ## build package - vsce package : 'vscode:prepublish' is executed diff --git a/HELP.MD b/HELP.MD index c35cb65a..0dda054a 100644 --- a/HELP.MD +++ b/HELP.MD @@ -121,6 +121,221 @@ Restart the language server after changing it. the same machine, with the `debugger` and `inets` applications available - see [Attach to a running node](./README.md#attach-to-a-running-node). +## MCP topology inspector (AI agents, debug sessions only) + +An opt-in, **read-only** [MCP](https://modelcontextprotocol.io) server that lets an AI agent +discover and explain the OTP topology (applications, supervisors, processes, modules, approved +ETS metadata) of the Erlang node you are debugging. It is a structural map, not a trace of +messages or business flows. Security model and residual risks: [mcp_security.md](./mcp_security.md). + +**Turn it on** (folder-scoped, default `false`) and start a *debug session* (launch or attach): + +```json +{ + "erlang.mcp.enabled": true, + "erlang.mcp.host": "127.0.0.1", + "erlang.mcp.port": 0 +} +``` + +- It starts only inside the debugged node, only for a real debug session (never in a normal + editor session, with *Run without debugging*, or from rebar/eunit) and stops with the session. + Settings are read per debug folder when the session starts; changing them does not affect a + running session. +- `host` must be loopback (`127.0.0.1`, `::1`, ...); `0.0.0.0`, LAN and public addresses are + rejected. `port: 0` (default) lets the OS choose. +- Any MCP problem (bad policy, busy port, untrusted workspace) is reported and disables **only** the + inspector; debugging continues. There is no "required" option. +- Same machine only: the client must reach the node's loopback; no port forwarding is supported. + Attach needs the `crypto` application in the target in addition to `debugger` and `inets`. + +**Connect a client** + +- *VS Code* (1.101+, MCP provider API): the running session appears as an MCP server named + `Erlang debug: ` in the MCP server list (*MCP: List Servers*); the per-session token is + handed to VS Code automatically and never printed. It disappears when the session ends. +- *Claude Code (and other static clients)*: Claude Code does not read VS Code's MCP list. For a + one-time setup, set the **user** setting `erlang.mcp.authToken` to a fixed development token + (16-128 chars `A-Za-z0-9._~-`, not read from workspace settings) and a fixed `erlang.mcp.port`, then once: + `claude mcp add --transport http erlang-debug http://127.0.0.1:/mcp --header "Authorization: Bearer "`. + The server is only reachable during a debug session. A fixed token is a convenience that trades + away the per-session credential: anyone knowing it who can reach the loopback port can read the + inspector's metadata. Leave it empty (default) for a fresh random token per session. +- *Any other client*: run **Erlang MCP: Show Connection Details** (select the session if several) + and choose *Copy URL* / *Copy Authorization header* (explicit action; nothing is copied + automatically). Set a fixed `erlang.mcp.port` if the client needs a stable URL: the token + still changes at every session and must be supplied as `Authorization: Bearer `. If the + fixed port is busy MCP is disabled for the session (no silent rebinding). +- Protocol: MCP `2025-06-18`, Streamable HTTP with JSON responses (no SSE, no resources). One prompt, `map_application` (argument `application`), carries the sample prompt below, so a client that lists MCP prompts (e.g. VS Code: `/mcp..map_application`) can run it directly; it grants nothing. + +**Project policy (optional)** - a literal top-level term in the *target project's* `rebar.config` +(nearest file from the debug `cwd`, never above the workspace folder; no `rebar.config.script`, +no profiles). It controls tools, tables and limits only; activation, host, port and +credentials come only from the VS Code settings. The values below are the defaults: + +```erlang +{mcp, [ + {allowed_tools, [<<"runtime_summary">>, <<"application_overview">>, <<"supervision_tree">>, + <<"process_info">>, <<"registered_processes">>, <<"ets_tables">>, + <<"debug_session">>, <<"top_processes">>, <<"topology_overview">>, <<"changes_since">>, + <<"top_ports">>, <<"ets_summary">>, <<"process_groups">>]}, + {allowed_ets_tables, []}, %% exact names, e.g. [<<"orders_index">>], or `all`; private tables never + {limits, [{max_request_bytes, 16384}, {max_result_bytes, 262144}, {max_items, 500}, + {max_depth, 16}, {max_binary_bytes, 4096}, {max_traversal_depth, 8}, + {max_concurrency, 2}, {request_timeout_ms, 3000}, {collection_ttl_ms, 30000}, + {max_collection_bytes, 1048576}, {max_collections, 2}]} +]}. +``` + +`allowed_tools` is an allowlist: a tool that is not listed is hidden, including tools added in later +versions of the extension. To follow the built-in set write `{allowed_tools, default}`, or +`{allowed_tools, [default, <<"process_state">>]}` to add developer tools to it. When the policy +disables tools, the server says so in its MCP `instructions` (so the agent can tell you why a tool +is missing instead of silently using a fallback). + +A limit can be raised above its default up to a hard ceiling (never beyond): `max_request_bytes` 65536, +`max_result_bytes` 2097152, `max_items` 5000, `max_depth` 32, `max_binary_bytes` 65536, +`max_traversal_depth` 16, `max_concurrency` 8, `request_timeout_ms` 15000, `collection_ttl_ms` 600000, +`max_collection_bytes` 8388608, `max_collections` 16. Raising `collection_ttl_ms` and `max_collections` +is what makes `changes_since` useful over a longer debugging session. + +**Developer tier (opt-in)** - on your own machine, with your own code, metadata alone is often not +enough ("why is this process stuck?"). Three more tools return a *bounded sample of application +data*; they are never enabled by default and must be named in `allowed_tools`. Remember that +whatever a tool returns goes into the agent's context, i.e. to the model provider: + +```erlang +{mcp, [ + {allowed_tools, [<<"runtime_summary">>, <<"application_overview">>, <<"supervision_tree">>, + <<"process_info">>, <<"top_processes">>, <<"topology_overview">>, + <<"process_state">>, <<"mailbox_sample">>, <<"ets_sample">>]}, + {allowed_ets_tables, all}, %% or exact names; also lets ets_summary count every non-private table + {limits, [{max_items, 2000}, {max_result_bytes, 1048576}, {request_timeout_ms, 10000}, + {collection_ttl_ms, 300000}, {max_collections, 8}]} +]}. +``` + +| Tool | Returns | +| --- | --- | +| `process_state` | `sys:get_state` of one gen_server / gen_statem / gen_event process (never a supervisor: its state holds the child start arguments; never a plain process: it is never sent a system message), bounded by depth, items and binary size. A process stopped at a breakpoint is reported as such | +| `mailbox_sample` | queue length and the oldest few messages (default 5, max 20) of one process; a mailbox of more than 20000 messages is not copied | +| `ets_sample` | a few objects (default 5, max 20) of one approved, non-private named table | + +Redaction is best effort: values of keys or map entries named like a secret (`password`, `token`, +`secret`, `api_key`, `cookie`, `auth`...) and credentials in URLs (`scheme://user:pw@host`) are replaced, +but a record without keys, or a secret in an unlabelled term, cannot be recognised. + +A malformed or duplicated term, an `mcp` term only inside a profile, or keys such as `enabled`, +`host`, `port`, `required` or a token disable MCP for that session with an explanation. + +**Tools** (all read-only, strict input schemas, discoverable through `tools/list`) + +| Tool | Purpose | +| --- | --- | +| `runtime_summary` | OTP/ERTS versions, node (host redacted by default), uptime, schedulers, run queue, process/atom/port/ETS counts against their VM limits (`resources`), connected node names (host redacted by default), memory, session id | +| `application_overview` | started applications, versions, declared dependencies, root supervisor ids, optional module names/behaviours; `application`, `includeModules`, `cursor` | +| `supervision_tree` | typed `supervises` edges from an application/supervisor id or a named supervisor; child role, modules, restart/shutdown; `id`/`supervisor`, `maxDepth`, `includeModules`, `cursor` | +| `registered_processes` | locally registered names with status/queue length and membership (`confirmed`/`inferred`/`unknown`); `application`, `cursor` | +| `process_info` | allowlisted fields of one process (by `id`, `name` or `pid`), links/monitors as ids; never dictionary, stack, mailbox or state | +| `ets_tables` | metadata of the approved named tables (protection, type, owner, size, memory in words and bytes) - never keys or values | +| `topology_overview` | one call for one application (`name` or `application` id): the application, its supervision tree with child counts, registered processes and the approved ETS tables they own, as one collection. Parts whose own tool is not allowed by the policy are left out (`policy_denied` omission) | +| `changes_since` | what changed since an earlier collection (`collectionId`): added, removed and replaced (restarted: same child, new id) entities, with `absence: unconfirmed` for removals from a partial collection. The baseline is only retained for `collection_ttl_ms` (30 s by default) and 2 collections: compare soon after the first call, else `baseline_expired` | +| `process_groups` | every local process aggregated by where it was started (initial call; the callback module for gen_server and friends): the top groups (`limit` up to 50, `sortBy` count / memory / reductions / message_queue_len) with count, share of the node, registered vs unregistered, memory, queue, inferred applications and 3 sample process ids. The answer has a fixed size whatever the number of processes (40,000 anonymous workers = one group); finds fan-out and leaks | +| `top_ports` | local ports ranked by queue size, bytes in or bytes out (`sortBy`, `limit` up to 50): driver name, counters, queue size and the owner process (`owns_port` edge). Never addresses, command lines, paths or data; a non-driver port (e.g. a spawned program) is `` | +| `ets_summary` | ETS memory per owner process, largest first (`limit` up to 50): table count and memory in words/bytes, plus node totals. Counts only tables approved by `allowed_ets_tables` (exact names or `all`), never private ones; no table name, key or value, no table inventory (use `ets_tables` for approved tables) | +| `top_processes` | the hottest local processes ranked by message queue length, reductions or memory (`sortBy`, `limit` up to 50): status, current function, initial call, name, membership. Bounded point-in-time scan, never mailbox/state | +| `debug_session` | launch/attach, interpreted modules, breakpoint locations (module and line), processes currently stopped at a breakpoint (`pausedProcesses`: process id, module, line) | + +The developer-tier tools above (`process_state`, `mailbox_sample`, `ets_sample`) are only listed when the project enables them. + +Graph tools also take `detail`, `pageSize` and `format`. `format: "mermaid"` adds a `mermaid` field (a `graph TD` +of the returned page, built from the returned edges only) so that an agent can render the diagram without redrawing it. `detail: "summary"` (default) is meant for agents: +same entities, ids and edges, without rarely needed fields (pids, descriptions, per-edge +`observedAt`, coverage limitations after the first page), 100 items per page, and module lists only +for one selected application. `detail: "full"` returns every field in the largest pages (policy +`max_items`): ask the agent for it when you want the complete JSON, e.g. to save it to a file. + +Graph tools return a versioned envelope: `schemaVersion`, `sessionId`, `collectionId`, +`startedAt`, `finishedAt`, `scope` (with `limitations`), `entities`, `relationships`, +`complete`, `truncated`, `omissions`, `nextCursor`. Entity kinds: `application`, `process`, +`module`, `ets_table` (a supervisor is a process with `role: supervisor`). Edge types: +`depends_on`, `supervises`, `owns_table`, `owns_port`, `linked_to`, `monitors`, `belongs_to`, `uses_module`, each +with `evidence`, `observedAt` and `confidence` (`confirmed`/`inferred`/`unknown`). Links and +monitors never imply supervision or message traffic. IDs are opaque, valid only within the session +and change when a process restarts or a table is recreated. A collection is an observation +interval, not an atomic snapshot: absence from a partial collection is not proof of termination. +Units: timestamps are UTC ISO-8601, `memory` of a process is bytes, of a table words +(`memoryBytes` is derived), `uptimeMs` is milliseconds. + +`omissions` (`policy_denied`, `disappeared`, `timeout`, `unavailable_while_paused`, `limit_reached`, +`unknown_membership`) explain what is missing and whether it is resumable; cursors are bound to +the session, the collection, the tool and the arguments, and return `cursor_expired` after the +retention time - a traversal is never silently restarted. Partial example (a supervisor blocked at +a breakpoint or busy): + +```json +{ "complete": false, "truncated": false, "nextCursor": null, + "omissions": [ { "reason": "timeout", "count": 1, "scope": "p_51ad...", + "resumable": false, "message": "the supervisor did not answer in time; its children are missing from this map" } ], + "relationships": [ { "type": "supervises", "from": "p_0a1...", "to": "p_51ad...", "confidence": "confirmed", + "evidence": "supervisor:which_children/1", "observedAt": "2026-01-01T10:00:00.000Z" } ] } +``` + +**Agent workflow**: `runtime_summary` + `application_overview` -> pick an application, follow its +root ids into `supervision_tree` (expand subtrees with the ids in `limit_reached` omissions) -> +enrich with `process_info`, `registered_processes`, `top_processes`, `ets_tables`, `debug_session` -> join by ids, +keep confirmed edges apart from inferences, and report the omissions. Module names can then be +correlated with the workspace/LSP tools; the inspector never returns source or code paths. + +**Sample prompt** (also served as the MCP prompt `map_application`): once the client is connected and the node is paused or running under the +debugger, paste this into the agent chat (Copilot agent mode, Claude Code...), replacing `` with +your application name. It follows the workflow above and asks for a diagram, a table and the +coverage gaps, without guessing beyond what the tools return: + +```text +Using the erlang-otp-topology-inspector MCP server, map the OTP topology of application +in the node I am debugging. Use only the tools that tools/list returns. + +Steps: +1. topology_overview(name=""): the application, its supervision tree, registered processes + and owned approved ETS tables in one call. If an omission says limit_reached or timeout, + expand that subtree with supervision_tree and the id it gives. (Without topology_overview: + runtime_summary, application_overview, supervision_tree from each root, + registered_processes(application=).) +2. top_processes (or process_info) only for processes that matter: supervisors, registered + workers, anything with a non-zero message queue. If debug_session reports processes stopped + at a breakpoint, say so: their supervisors may be reported as unavailable_while_paused. +3. ets_tables and debug_session only if they relate to the application. + +Output: +- A Mermaid `graph TD` of the supervision tree (edge = supervises; label children with kind and + restart type). Tools take format=mermaid and return the diagram from the reported edges. +- A table: name/pid, module, kind, restart, msg queue len, membership (confirmed/inferred). +- "Coverage gaps": every truncated/omission/timeout reported by the tools. +- 3-5 observations (single points of failure, deep trees, hot queues, temporary children). + +Rules: use only what the tools returned. Structural edges are not message traffic, so do not +describe runtime call flows. Keep detail=summary unless I ask for the full JSON. +``` + +Example of the agent's answer (supervision tree diagram, then the process table): + +![MCP inspector result](images/vscode-erlang-mcp-results.png) + +Narrower questions work too, e.g. *"Which processes are hot? Use top_processes by message_queue_len and say which application each belongs to"*, *"Why is `` slow? Use process_info on it and compare +its queue length and reductions with its siblings in the supervision tree"*, or *"Where are my +breakpoints in the supervision tree? Use debug_session and supervision_tree with includeModules"*. + +**Request journal**: every MCP request of the session is written to the **Output > Erlang MCP** +channel - request number, tool, status, time spent in the inspector, response size, entities / +relationships, page and cursor use, and the delay since the previous request (time spent by the +agent) - with a summary when the session ends. Arguments, results and the token are never written. + +**Breakpoints**: inspection never resumes, suspends or alters application processes. A process +stopped at a breakpoint still answers `process_info`; a supervisor that cannot answer in time +yields a `timeout` omission (never a guess that it is paused). The target stops the inspector +within ~10 seconds if the debug adapter stops renewing its lease. + ## Changing the debugger mode has no effect `erlang.debuggerRunMode` is read at extension startup: restart VS Code after diff --git a/README.md b/README.md index fe514803..9a611b9e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ New to the extension? Open **Help > Welcome > Get started with Erlang**: a walkt | Testing | EUnit and Common Test in the Testing view: run, debug, coverage | | Build | rebar3 commands, rebar3 tasks (compile, eunit, ct, dialyzer, release, shell, clean), Dialyzer warnings in Problems with PLT status in the status bar | | Debugger | Launch or attach to a running node; line, function, conditional, hit-count breakpoints, logpoints; variables, call stack, inline values | +| AI agents | Opt-in, read-only MCP server inside the debugged node: an agent maps its applications, supervision trees, processes and ETS metadata | | Status | Language server state and OTP version in the status bar | @@ -180,6 +181,18 @@ The debugger starts a hidden helper node, connects to `node`, loads its bridge t - Cookie: `~/.erlang.cookie` by default, or a `"cookie"` entry. - Project modules must be compiled with `debug_info` (rebar3 default). +### MCP topology inspector (AI agents) + +Set `"erlang.mcp.enabled": true` and start a debug session (launch or attach). The debugged node then serves a read-only [MCP](https://modelcontextprotocol.io) server on loopback, so that an AI agent (Copilot agent mode, Claude Code...) can discover and explain its OTP topology: applications, supervision trees, processes, registered names and approved ETS tables. It stops with the debug session and never runs in a normal editor session. + +![mcpInspector](images/vscode-erlang-mcp-running.png) + +In VS Code the session shows up in the MCP server list on its own; other clients connect with **Erlang MCP: Show Connection Details**. Then ask the agent, e.g. *"map the OTP topology of application ``"*. Setup, project policy, tools and a ready-to-use [sample prompt](./HELP.MD#mcp-topology-inspector-ai-agents-debug-sessions-only) are in HELP.MD; security model in [mcp_security.md](./mcp_security.md). + +The agent answers with a supervision tree diagram and a process table (here a worker with 18 queued messages stands out): + +![mcpResults](images/vscode-erlang-mcp-results.png) + ## Using this extension in Erlang Docker instance Clone this repo, and try it : @@ -223,6 +236,9 @@ For Erlang files the extension sets 4-space indentation (erlfmt's), enables sema - `erlang.verboseExcludeFilter` - LSP methods excluded from technical traces - `erlang.debuggerRunMode` - How the debug adapter is run (`external`, `server`, `inline`) - `erlang.useShell` - Whether erl/escript/erlc are spawned through a shell (`auto`: Windows only, `always`, `never`) +- `erlang.mcp.enabled` - Start the MCP topology inspector in debug sessions (default `false`) +- `erlang.mcp.host`, `erlang.mcp.port` - Loopback address and port of the inspector (`0` = any free port) +- `erlang.mcp.authToken` - Optional fixed bearer token (user setting, development convenience; empty = random per session) ## Help diff --git a/apps/erlangbridge/src/mcp/mcp_audit.erl b/apps/erlangbridge/src/mcp/mcp_audit.erl new file mode 100644 index 00000000..477c9db6 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_audit.erl @@ -0,0 +1,19 @@ +%% Structured lifecycle/security events of the MCP inspector. +%% Only event names and bounded, non-sensitive fields are logged: never the +%% token, request parameters or returned runtime data. +-module(mcp_audit). + +-export([event/2]). + +-define(ALLOWED_FIELDS, [tool, status, duration_ms, reason, method, code, port, host_ok, count]). + +event(Kind, Fields) when is_atom(Kind), is_map(Fields) -> + Safe = maps:with(?ALLOWED_FIELDS, Fields), + try + case erlang:function_exported(logger, info, 2) of + true -> logger:info("mcp: ~p ~p", [Kind, Safe]); + false -> error_logger:info_msg("mcp: ~p ~p~n", [Kind, Safe]) + end + catch _:_ -> ok + end, + ok. diff --git a/apps/erlangbridge/src/mcp/mcp_encoder.erl b/apps/erlangbridge/src/mcp/mcp_encoder.erl new file mode 100644 index 00000000..03c49195 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_encoder.erl @@ -0,0 +1,174 @@ +%% Bounded conversion of Erlang terms to JSON-encodable terms (binary keys, +%% binaries, numbers, booleans, null, lists, maps). Never creates atoms, never +%% exposes an executable handle: funs, ports, references and pids become text. +-module(mcp_encoder). + +-export([term/2, text/2, safe_binary/2, hex/1, iso8601/1, pid_text/1, scrub/1]). + +-define(SECRET_WORDS, ["password", "passwd", "secret", "token", "cookie", + "credential", "apikey", "api_key", "private_key", "auth"]). + +%% Limits: #{max_depth, max_items, max_binary_bytes} +term(Term, Limits) -> + enc(Term, 0, Limits). + +%% Short printable text of any term, bounded. +text(Term, Limits) -> + Max = maps:get(max_binary_bytes, Limits, 4096), + Depth = maps:get(max_depth, Limits, 16), + Str = try lists:flatten(io_lib:format("~0P", [redact(Term, Depth), Depth])) + catch _:_ -> "" + end, + safe_binary(scrub(unicode:characters_to_binary(Str, unicode, utf8)), Max). + +%% Best-effort redaction of what looks like a credential: the value of a key/value +%% pair or map entry whose key names a secret (password, token, ...). Applied to the +%% printed form of terms (child ids, ...); a record without keys cannot be redacted. +redact(_, D) when D =< 0 -> ''; +redact({K, V}, D) when is_atom(K); is_binary(K) -> + case secret_key(K) of + true -> {K, ''}; + false -> {K, redact(V, D - 1)} + end; +redact(T, D) when is_tuple(T) -> list_to_tuple([redact(E, D - 1) || E <- lists:sublist(tuple_to_list(T), 64)]); +redact(M, D) when is_map(M) -> + maps:map(fun(K, V) -> case secret_key(K) of true -> ''; false -> redact(V, D - 1) end end, M); +redact([H | T], D) -> [redact(H, D - 1) | redact_tail(T, D - 1, 64)]; +redact(T, _) -> T. + +redact_tail(_, _, 0) -> []; +redact_tail([H | T], D, N) -> [redact(H, D) | redact_tail(T, D, N - 1)]; +redact_tail(Tail, _, _) -> Tail. + +%% user:password@ inside a URL-like text +scrub(Bin) when is_binary(Bin) -> + re:replace(Bin, "://[^/@\\s:]+:[^@\\s]+@", "://@", [global, {return, binary}]); +scrub(Other) -> Other. + +enc(_, Depth, #{max_depth := Max}) when Depth > Max -> + <<"">>; +enc(T, _, _) when is_integer(T) -> T; +enc(T, _, _) when is_float(T) -> T; +enc(true, _, _) -> true; +enc(false, _, _) -> false; +enc(undefined, _, _) -> null; +enc(T, _, _) when is_atom(T) -> + safe_binary(atom_to_binary(T, utf8), 255); +enc(T, _, #{max_binary_bytes := Max}) when is_binary(T) -> + case binary(T, Max) of + B when is_binary(B) -> scrub(B); + Other -> Other + end; +enc(T, _, _) when is_pid(T) -> pid_text(T); +enc(T, _, _) when is_port(T) -> #{<<"port">> => list_bin(erlang:port_to_list(T))}; +enc(T, _, _) when is_reference(T) -> #{<<"ref">> => list_bin(erlang:ref_to_list(T))}; +enc(T, _, _) when is_function(T) -> + %% Descriptive only. The fun itself is never reachable from the result. + case erlang:fun_info(T, module) of + {module, M} -> #{<<"fun">> => safe_binary(atom_to_binary(M, utf8), 255)}; + _ -> #{<<"fun">> => <<"unknown">>} + end; +enc({K, V}, Depth, Limits) when is_atom(K); is_binary(K) -> + %% {password, "x"} style pairs (proplists, keyword options) + #{<<"tuple">> => [enc(K, Depth + 1, Limits), enc_value(K, V, Depth + 1, Limits)]}; +enc(T, Depth, Limits) when is_tuple(T) -> + #{<<"tuple">> => enc_list(tuple_to_list(T), Depth + 1, Limits)}; +enc(T, Depth, Limits) when is_map(T) -> + Max = maps:get(max_items, Limits), + Pairs = lists:sublist(lists:sort(maps:to_list(T)), Max), + #{<<"map">> => [#{<<"key">> => enc(K, Depth + 1, Limits), + <<"value">> => enc_value(K, V, Depth + 1, Limits)} || {K, V} <- Pairs], + <<"size">> => map_size(T)}; +enc([], _, _) -> []; +enc(T, Depth, Limits) when is_list(T) -> + case printable(T) of + true -> safe_binary(scrub(unicode:characters_to_binary(T, unicode, utf8)), maps:get(max_binary_bytes, Limits)); + false -> enc_list(T, Depth + 1, Limits) + end; +enc(_, _, _) -> + <<"">>. + +enc_value(Key, Value, Depth, Limits) -> + case secret_key(Key) of + true -> <<"">>; + false -> enc(Value, Depth, Limits) + end. + +secret_key(Key) when is_atom(Key) -> secret_key(atom_to_list(Key)); +secret_key(Key) when is_binary(Key) -> + secret_key(binary_to_list(Key)); +secret_key(Key) when is_list(Key) -> + try + Lower = string:to_lower(Key), + lists:any(fun(W) -> string:str(Lower, W) > 0 end, ?SECRET_WORDS) + catch _:_ -> false + end; +secret_key(_) -> false. + +%% Proper or improper list, capped by max_items. +enc_list(L, Depth, #{max_items := Max} = Limits) -> + {Items, Rest} = take(L, Max, []), + Encoded = [enc(I, Depth, Limits) || I <- Items], + case Rest of + [] -> Encoded; + R when is_list(R) -> Encoded ++ [#{<<"truncated">> => true}]; + Tail -> Encoded ++ [#{<<"improperTail">> => enc(Tail, Depth, Limits)}] + end. + +take([H | T], N, Acc) when N > 0 -> take(T, N - 1, [H | Acc]); +take(Rest, _, Acc) -> {lists:reverse(Acc), Rest}. + +printable(L) -> + try io_lib:printable_unicode_list(L) catch _:_ -> false end. + +binary(B, Max) -> + case is_utf8(B) andalso byte_size(B) =< Max of + true -> B; + false -> + Size = byte_size(B), + Head = binary:part(B, 0, min(Size, Max)), + #{<<"binary">> => #{<<"size">> => Size, + <<"encoding">> => <<"base64">>, + <<"data">> => base64:encode(Head), + <<"truncated">> => Size > Max}} + end. + +%% A valid UTF-8 binary cut at Max bytes, on a character boundary. +safe_binary(B, Max) when is_binary(B) -> + case is_utf8(B) of + true when byte_size(B) =< Max -> B; + true -> cut_utf8(B, Max); + false -> <<"">> + end. + +cut_utf8(B, Max) -> + Head = binary:part(B, 0, Max), + case unicode:characters_to_binary(Head, utf8, utf8) of + R when is_binary(R) -> R; + {incomplete, Good, _} -> Good; + _ -> <<>> + end. + +is_utf8(B) -> + case unicode:characters_to_binary(B, utf8, utf8) of + R when is_binary(R) -> true; + _ -> false + end. + +list_bin(L) -> list_to_binary(L). + +pid_text(Pid) -> list_to_binary(pid_to_list(Pid)). + +hex(Bin) -> + << <<(hex_digit(N div 16)), (hex_digit(N rem 16))>> || <> <= Bin >>. + +hex_digit(N) when N < 10 -> $0 + N; +hex_digit(N) -> $a + N - 10. + +%% Unix milliseconds -> "2026-01-01T00:00:00.000Z" +iso8601(Ms) -> + Secs = Ms div 1000, + {{Y, Mo, D}, {H, Mi, S}} = calendar:gregorian_seconds_to_datetime( + Secs + calendar:datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}})), + iolist_to_binary(io_lib:format("~4..0w-~2..0w-~2..0wT~2..0w:~2..0w:~2..0w.~3..0wZ", + [Y, Mo, D, H, Mi, S, Ms rem 1000])). diff --git a/apps/erlangbridge/src/mcp/mcp_policy.erl b/apps/erlangbridge/src/mcp/mcp_policy.erl new file mode 100644 index 00000000..a3bb9b31 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_policy.erl @@ -0,0 +1,531 @@ +%% Configuration and policy of the embedded MCP inspector. +%% +%% Two sources, with disjoint ownership: +%% * VS Code settings (erlang.mcp.enabled/host/port): activation and binding; +%% * the optional top-level `{mcp, [...]}` term of the target project's +%% rebar.config: tool/table allowlists and *lower* resource limits. +%% Neither can exceed the built-in ceilings. +%% +%% The project file is read by a bounded, non-evaluating reader (no +%% rebar.config.script, no profile merging, no includes). It interns the atoms +%% of the file, so it must only run in a short-lived helper VM (see cli/0) - +%% never in the long-lived LSP or target VM. +%% +%% Normalized configuration (internal contract, atom keys) : +%% #{host => "127.0.0.1", port => 0, +%% allowed_tools => [<<"runtime_summary">>, ...], (project term: a list, `default`, or [default, <<"tool">>]) +%% allowed_ets_tables => [<<"orders_index">>], +%% limits => #{max_items => 500, ...}} +%% and its JSON form (camelCase binary keys) which crosses to the target. +-module(mcp_policy). + +-include_lib("kernel/include/file.hrl"). + +-export([all_tools/0, default_tools/0, defaults/0, default_limits/0, ceilings/0, limit/2, tool_allowed/2]). +-export([ets_table_allowed/2]). +-export([resolve/1, read_project_policy/2, normalize_project_term/1]). +-export([validate_host/1, validate_config/1, to_json_map/1, from_json_map/1, validate_token/1]). +-export([cli/0]). + +-define(MAX_PROJECT_FILE_BYTES, 262144). +-define(MAX_PROJECT_TOKENS, 20000). +-define(PROJECT_FILE, "rebar.config"). + +%% Enabled unless the project policy narrows them (metadata only). +-define(DEFAULT_TOOLS, [<<"runtime_summary">>, <<"application_overview">>, + <<"supervision_tree">>, <<"process_info">>, + <<"registered_processes">>, <<"ets_tables">>, + <<"debug_session">>, <<"top_processes">>, <<"topology_overview">>, <<"changes_since">>, + <<"top_ports">>, <<"ets_summary">>, <<"process_groups">>]). +%% Developer tier: they return data of the debugged application (a bounded process +%% state, a few mailbox messages, a few table rows), so a project must name them in +%% allowed_tools; they are never enabled by default. +-define(DEV_TOOLS, [<<"process_state">>, <<"mailbox_sample">>, <<"ets_sample">>]). +-define(TOOLS, (?DEFAULT_TOOLS ++ ?DEV_TOOLS)). + +%% {internal key, project key, JSON key, default, ceiling}: a project may raise a +%% limit above its default up to the ceiling, never above. +-define(LIMITS, + [{max_request_bytes, max_request_bytes, <<"maxRequestBytes">>, 16384, 65536}, + {max_result_bytes, max_result_bytes, <<"maxResultBytes">>, 262144, 2097152}, + {max_items, max_items, <<"maxItems">>, 500, 5000}, + {max_depth, max_depth, <<"maxDepth">>, 16, 32}, + {max_binary_bytes, max_binary_bytes, <<"maxBinaryBytes">>, 4096, 65536}, + {max_traversal_depth, max_traversal_depth, <<"maxTraversalDepth">>, 8, 16}, + {max_concurrency, max_concurrency, <<"maxConcurrency">>, 2, 8}, + {request_timeout_ms, request_timeout_ms, <<"requestTimeoutMs">>, 3000, 15000}, + {collection_ttl_ms, collection_ttl_ms, <<"collectionTtlMs">>, 30000, 600000}, + {max_collection_bytes, max_collection_bytes, <<"maxCollectionBytes">>, 1048576, 8388608}, + {max_collections, max_collections, <<"maxCollections">>, 2, 16}]). + +%% Keys a project term must never carry (owned by VS Code / the runtime). +-define(FORBIDDEN_PROJECT_KEYS, + [enabled, required, host, port, transport, auth_token, token, + credentials, cookie, authorization, bearer]). + +all_tools() -> ?TOOLS. +default_tools() -> ?DEFAULT_TOOLS. + +default_limits() -> + maps:from_list([{K, D} || {K, _, _, D, _} <- ?LIMITS]). + +ceilings() -> + maps:from_list([{K, C} || {K, _, _, _, C} <- ?LIMITS]). + +defaults() -> + #{host => "127.0.0.1", + port => 0, + allowed_tools => ?DEFAULT_TOOLS, + allowed_ets_tables => [], + limits => default_limits()}. + +%% Is the named table approved? allowed_ets_tables is a list of exact names, or `all`. +ets_table_allowed(_Name, #{allowed_ets_tables := all}) -> true; +ets_table_allowed(Name, #{allowed_ets_tables := Names}) -> lists:member(Name, Names). + +limit(Key, #{limits := Limits}) -> maps:get(Key, Limits). + +tool_allowed(Tool, #{allowed_tools := Tools}) -> lists:member(Tool, Tools). + +%%------------------------------------------------------------------------------ +%% Host validation: loopback literals or names resolving only to loopback. +%%------------------------------------------------------------------------------ + +%% -> {ok, LiteralAddressString} | {error, Message} +validate_host(Host) when is_binary(Host) -> + validate_host(binary_to_list(Host)); +validate_host(Host) when is_list(Host), length(Host) > 0, length(Host) =< 255 -> + case inet:parse_strict_address(Host) of + {ok, Ip} -> check_loopback(Ip, Host); + {error, _} -> resolve_loopback(Host) + end; +validate_host(_) -> + {error, "erlang.mcp.host must be a non-empty loopback address"}. + +check_loopback(Ip, Text) -> + case is_loopback(Ip) of + true -> {ok, inet:ntoa(Ip)}; + false -> + {error, lists:flatten(io_lib:format( + "erlang.mcp.host '~ts' is not a loopback address: only 127.0.0.0/8 and ::1 are accepted", [Text]))} + end. + +resolve_loopback(Name) -> + Ips = lists:usort([Ip || Fam <- [inet, inet6], + {ok, Ip} <- [safe_getaddr(Name, Fam)]]), + case Ips of + [] -> + {error, lists:flatten(io_lib:format("erlang.mcp.host '~ts' cannot be resolved", [Name]))}; + _ -> + case lists:all(fun is_loopback/1, Ips) of + true -> {ok, inet:ntoa(hd(prefer_v4(Ips)))}; + false -> + {error, lists:flatten(io_lib:format( + "erlang.mcp.host '~ts' resolves to a non-loopback address", [Name]))} + end + end. + +prefer_v4(Ips) -> + {V4, V6} = lists:partition(fun(Ip) -> tuple_size(Ip) =:= 4 end, Ips), + V4 ++ V6. + +safe_getaddr(Name, Family) -> + try inet:getaddr(Name, Family) catch _:_ -> {error, einval} end. + +is_loopback({127, _, _, _}) -> true; +is_loopback({0, 0, 0, 0, 0, 0, 0, 1}) -> true; +is_loopback(_) -> false. + +%%------------------------------------------------------------------------------ +%% Settings + project policy -> normalized configuration +%%------------------------------------------------------------------------------ + +%% Input (binary-keyed map, decoded JSON) : +%% <<"enabled">>, <<"host">>, <<"port">>, <<"cwd">>, <<"root">>, <<"trusted">> +%% -> disabled | {ok, Config} | {error, Message} +resolve(Input) when is_map(Input) -> + case maps:get(<<"enabled">>, Input, false) of + true -> resolve_enabled(Input); + _ -> disabled + end. + +resolve_enabled(Input) -> + case maps:get(<<"trusted">>, Input, false) of + true -> + Host = maps:get(<<"host">>, Input, <<"127.0.0.1">>), + Port = maps:get(<<"port">>, Input, 0), + case {validate_host(Host), valid_port(Port)} of + {{error, Msg}, _} -> {error, Msg}; + {_, false} -> {error, "erlang.mcp.port must be an integer between 0 and 65535"}; + {{ok, Literal}, true} -> + resolve_project(Input, Literal, Port) + end; + _ -> + {error, "MCP is disabled: the workspace is not trusted"} + end. + +resolve_project(Input, Host, Port) -> + Cwd = to_list(maps:get(<<"cwd">>, Input, <<>>)), + Root = to_list(maps:get(<<"root">>, Input, <<>>)), + case read_project_policy(Cwd, Root) of + {error, Msg} -> {error, Msg}; + {ok, none} -> finish(Host, Port, #{}); + {ok, Project} -> finish(Host, Port, Project) + end. + +finish(Host, Port, Project) -> + Base = (defaults())#{host => Host, port => Port}, + Merged = maps:merge(Base, maps:without([limits], Project)), + Limits = maps:merge(maps:get(limits, Base), maps:get(limits, Project, #{})), + Config = Merged#{limits => Limits}, + case validate_config(Config) of + ok -> {ok, Config}; + {error, Msg} -> {error, Msg} + end. + +valid_port(P) -> is_integer(P) andalso P >= 0 andalso P =< 65535. + +to_list(B) when is_binary(B) -> + case unicode:characters_to_list(B) of + L when is_list(L) -> L; + _ -> binary_to_list(B) + end; +to_list(L) when is_list(L) -> L. + +%%------------------------------------------------------------------------------ +%% Project policy reader +%%------------------------------------------------------------------------------ + +%% Nearest rebar.config from Cwd up to Root (inclusive), never above Root. +%% -> {ok, none} | {ok, #{allowed_tools|allowed_ets_tables|limits => ...}} | {error, Msg} +read_project_policy(Cwd, Root) -> + case find_project_file(Cwd, Root) of + {error, _} = E -> E; + none -> {ok, none}; + {ok, File} -> + case read_terms(File) of + {ok, Terms} -> project_policy(Terms); + {error, Msg} -> {error, Msg} + end + end. + +find_project_file(Cwd, Root) -> + C = normalize_dir(Cwd), + R = normalize_dir(Root), + case C =/= [] andalso R =/= [] andalso within(C, R) of + true -> walk_up(C, R); + false -> {error, "MCP is disabled: the debug cwd is outside the selected workspace folder"} + end. + +walk_up(Dir, Root) -> + File = filename:join(Dir, ?PROJECT_FILE), + case filelib:is_regular(File) of + true -> {ok, File}; + false when Dir =:= Root -> none; + false -> + Parent = filename:dirname(Dir), + case Parent =:= Dir of + true -> none; + false -> walk_up(Parent, Root) + end + end. + +normalize_dir([]) -> []; +normalize_dir(Dir) -> + Abs = filename:absname(Dir), + Parts = [P || P <- filename:split(Abs), P =/= "."], + Resolved = lists:foldl( + fun("..", [_ | Acc]) -> Acc; + ("..", []) -> []; + (P, Acc) -> [P | Acc] + end, [], Parts), + filename:join(lists:reverse(Resolved)). + +%% Path-component prefix check (not a string prefix: /a/bc is not within /a/b). +within(Dir, Root) -> + D = filename:split(Dir), + R = filename:split(Root), + lists:prefix(R, D). + +read_terms(File) -> + case file:read_file_info(File) of + {ok, #file_info{size = Size}} when Size > ?MAX_PROJECT_FILE_BYTES -> + {error, "rebar.config is too large to read the MCP policy (limit 256 KiB)"}; + {ok, _} -> + case file:read_file(File) of + {ok, Bin} -> scan_terms(Bin); + {error, _} -> {error, "rebar.config cannot be read"} + end; + {error, _} -> + {error, "rebar.config cannot be read"} + end. + +scan_terms(Bin) -> + case unicode:characters_to_list(Bin) of + Str when is_list(Str) -> + try + collect_terms(Str, 1, 0, []) + catch + throw:{malformed, Msg} -> {error, Msg} + end; + _ -> + {error, "rebar.config is not valid text (UTF-8 expected)"} + end. + +collect_terms(Str, Line, Count, Acc) -> + case erl_scan:tokens([], Str, Line) of + {done, {ok, Tokens, EndLine}, Rest} -> + Count1 = Count + length(Tokens), + Count1 > ?MAX_PROJECT_TOKENS andalso throw({malformed, "rebar.config is too complex to read the MCP policy"}), + Term = case erl_parse:parse_term(Tokens) of + {ok, T} -> T; + {error, _} -> throw({malformed, "rebar.config is malformed: it must contain only Erlang terms ending with '.'"}) + end, + collect_terms(Rest, EndLine, Count1, [Term | Acc]); + {done, {eof, _}, _} -> + {ok, lists:reverse(Acc)}; + {done, {error, _, _}, _} -> + throw({malformed, "rebar.config is malformed: it cannot be tokenized"}); + {more, Cont} -> + case erl_scan:tokens(Cont, eof, Line) of + {done, {ok, [], _}, _} -> {ok, lists:reverse(Acc)}; + {done, {eof, _}, _} -> {ok, lists:reverse(Acc)}; + _ -> throw({malformed, "rebar.config is malformed: incomplete term"}) + end + end. + +project_policy(Terms) -> + Mcp = [V || {mcp, V} <- Terms], + case Mcp of + [] -> + case profile_policy(Terms) of + true -> {error, "an 'mcp' policy inside a rebar profile is not supported: " + "move it to the top level of rebar.config"}; + false -> {ok, none} + end; + [Term] -> normalize_project_term(Term); + _ -> {error, "rebar.config has several top-level 'mcp' terms"} + end. + +profile_policy(Terms) -> + lists:any( + fun({profiles, Profiles}) when is_list(Profiles) -> + lists:any(fun({_Name, Opts}) when is_list(Opts) -> lists:keymember(mcp, 1, Opts); + (_) -> false + end, Profiles); + (_) -> false + end, Terms). + +%% The {mcp, Term} value -> {ok, #{...}} | {error, Msg} +normalize_project_term(Term) when is_list(Term) -> + try + Keys = [element(1, T) || T <- Term, is_tuple(T), tuple_size(T) =:= 2], + length(Keys) =:= length(Term) orelse throw("the mcp term must be a list of {Key, Value} pairs"), + length(Keys) =:= length(lists:usort(Keys)) orelse throw("the mcp term has duplicate keys"), + lists:foreach( + fun(K) -> + lists:member(K, ?FORBIDDEN_PROJECT_KEYS) andalso + throw(io_lib:format("'~w' cannot be set in rebar.config: it is owned by the VS Code Erlang settings", [K])), + lists:member(K, [allowed_tools, allowed_ets_tables, limits]) orelse + throw(io_lib:format("unknown key '~w' in the mcp term", [K])) + end, Keys), + {ok, lists:foldl(fun project_key/2, #{}, Term)} + catch + throw:Msg when is_list(Msg) -> {error, "invalid mcp policy: " ++ Msg}; + throw:Msg -> {error, "invalid mcp policy: " ++ lists:flatten(Msg)} + end; +normalize_project_term(_) -> + {error, "invalid mcp policy: the mcp term must be a list"}. + +project_key({allowed_tools, default}, Acc) -> + Acc#{allowed_tools => ?DEFAULT_TOOLS}; +project_key({allowed_tools, Tools0}, Acc) when is_list(Tools0) -> + %% the atom `default` stands for the built-in default set, so that a policy keeps up + %% with tools added later: [default, <<"process_state">>] + {Defaults, Explicit} = lists:partition(fun(T) -> T =:= default end, Tools0), + length(Defaults) =< 1 orelse throw("allowed_tools lists default twice"), + Tools = Explicit ++ [T || _ <- Defaults, T <- ?DEFAULT_TOOLS, not lists:member(T, Explicit)], + Names = binary_list(Tools, "allowed_tools"), + length(Names) =:= length(lists:usort(Names)) orelse throw("allowed_tools has duplicates"), + lists:foreach(fun(N) -> lists:member(N, ?TOOLS) orelse throw("allowed_tools has an unknown tool name") end, Names), + Acc#{allowed_tools => Names}; +project_key({allowed_tools, _}, _Acc) -> + throw("allowed_tools must be a list of tool names, optionally with default, or default"); +project_key({allowed_ets_tables, all}, Acc) -> + Acc#{allowed_ets_tables => all}; +project_key({allowed_ets_tables, Tables}, Acc) -> + Names = binary_list(Tables, "allowed_ets_tables"), + length(Names) =:= length(lists:usort(Names)) orelse throw("allowed_ets_tables has duplicates"), + lists:foreach(fun(N) -> byte_size(N) > 0 andalso byte_size(N) =< 255 orelse + throw("allowed_ets_tables entries must be 1..255 bytes") end, Names), + Acc#{allowed_ets_tables => Names}; +project_key({limits, Limits}, Acc) -> + is_list(Limits) orelse throw("limits must be a list"), + Pairs = [case L of {K, V} -> {K, V}; _ -> throw("limits must be {Key, Integer} pairs") end || L <- Limits], + Ks = [K || {K, _} <- Pairs], + length(Ks) =:= length(lists:usort(Ks)) orelse throw("limits has duplicate keys"), + Map = lists:foldl( + fun({K, V}, M) -> + case lists:keyfind(K, 2, ?LIMITS) of + false -> throw(io_lib:format("unknown limit '~w'", [K])); + {Internal, _, _, _, Ceiling} -> + (is_integer(V) andalso V > 0 andalso V =< Ceiling) orelse + throw(io_lib:format("limit '~w' must be a positive integer <= ~w", [K, Ceiling])), + M#{Internal => V} + end + end, #{}, Pairs), + Acc#{limits => Map}. + +binary_list(L, What) when is_list(L) -> + lists:foreach(fun(B) -> is_binary(B) orelse throw(What ++ " entries must be binaries such as <<\"name\">>") end, L), + L; +binary_list(_, What) -> + throw(What ++ " must be a list"). + +%%------------------------------------------------------------------------------ +%% Authoritative validation (runs again in the target before any listener). +%%------------------------------------------------------------------------------ + +validate_config(#{host := Host, port := Port, allowed_tools := Tools, + allowed_ets_tables := Tables, limits := Limits} = Config) -> + Known = [host, port, allowed_tools, allowed_ets_tables, limits], + Extra = maps:keys(maps:without(Known, Config)), + Checks = [fun() -> Extra =:= [] orelse {error, "unknown MCP configuration key"} end, + fun() -> case literal_loopback(Host) of true -> ok; false -> {error, "MCP host must be a loopback address literal"} end end, + fun() -> valid_port(Port) orelse {error, "MCP port must be an integer between 0 and 65535"} end, + fun() -> (is_list(Tools) andalso lists:all(fun(T) -> lists:member(T, ?TOOLS) end, Tools) + andalso length(Tools) =:= length(lists:usort(Tools))) + orelse {error, "invalid MCP tool allowlist"} end, + fun() -> Tables =:= all orelse + (is_list(Tables) andalso lists:all(fun is_binary/1, Tables) + andalso length(Tables) =:= length(lists:usort(Tables))) + orelse {error, "invalid MCP ETS table allowlist"} end, + fun() -> validate_limits(Limits) end], + run_checks(Checks); +validate_config(_) -> + {error, "incomplete MCP configuration"}. + +run_checks([]) -> ok; +run_checks([C | T]) -> + case C() of + ok -> run_checks(T); + true -> run_checks(T); + {error, _} = E -> E + end. + +literal_loopback(Host) when is_list(Host) -> + case inet:parse_strict_address(Host) of + {ok, Ip} -> is_loopback(Ip); + _ -> false + end; +literal_loopback(_) -> false. + +validate_limits(Limits) when is_map(Limits) -> + Expected = [K || {K, _, _, _, _} <- ?LIMITS], + case lists:sort(maps:keys(Limits)) =:= lists:sort(Expected) of + false -> {error, "MCP limits are incomplete or unknown"}; + true -> + Bad = [K || {K, _, _, _, Ceiling} <- ?LIMITS, + V <- [maps:get(K, Limits)], + not (is_integer(V) andalso V > 0 andalso V =< Ceiling)], + case Bad of + [] -> validate_relations(Limits); + _ -> {error, lists:flatten(io_lib:format("MCP limit(s) out of range: ~w", [Bad]))} + end + end; +validate_limits(_) -> + {error, "MCP limits must be a map"}. + +%% Error and result envelopes must fit. +validate_relations(#{max_result_bytes := Result, max_request_bytes := Request, + max_collection_bytes := Coll, max_items := Items, max_binary_bytes := Bin}) -> + if Result < 4096 -> {error, "max_result_bytes must be at least 4096"}; + Request < 1024 -> {error, "max_request_bytes must be at least 1024"}; + Coll < Result -> {error, "max_collection_bytes must be at least max_result_bytes"}; + Bin > Result div 4 -> {error, "max_binary_bytes must not exceed a quarter of max_result_bytes"}; + Items < 1 -> {error, "max_items must be positive"}; + true -> ok + end. + +%%------------------------------------------------------------------------------ +%% JSON form (crosses adapter -> target). Only fixed atoms are produced. +%%------------------------------------------------------------------------------ + +to_json_map(#{host := Host, port := Port, allowed_tools := Tools, + allowed_ets_tables := Tables, limits := Limits}) -> + #{<<"host">> => list_to_binary(Host), + <<"port">> => Port, + <<"allowedTools">> => Tools, + <<"allowedETSTables">> => case Tables of all -> <<"*">>; _ -> Tables end, + <<"limits">> => maps:from_list([{J, maps:get(K, Limits)} || {K, _, J, _, _} <- ?LIMITS])}. + +from_json_map(#{<<"host">> := Host, <<"port">> := Port, <<"allowedTools">> := Tools, + <<"allowedETSTables">> := Tables, <<"limits">> := Limits} = M) + when is_binary(Host), is_map(Limits) -> + Known = [<<"host">>, <<"port">>, <<"allowedTools">>, <<"allowedETSTables">>, + <<"limits">>, <<"mode">>, <<"authToken">>], + case maps:keys(maps:without(Known, M)) of + [] -> + case token_of(M) of + {error, _} = TE -> TE; + _ -> + KnownLimits = [J || {_, _, J, _, _} <- ?LIMITS], + case maps:keys(maps:without(KnownLimits, Limits)) of + [] -> + Internal = maps:from_list([{K, maps:get(J, Limits)} + || {K, _, J, _, _} <- ?LIMITS, maps:is_key(J, Limits)]), + Config = #{host => binary_to_list(Host), port => Port, + allowed_tools => Tools, + allowed_ets_tables => case Tables of <<"*">> -> all; _ -> Tables end, + limits => Internal}, + case validate_config(Config) of + ok -> {ok, Config}; + {error, _} = E -> E + end; + _ -> {error, "unknown MCP limit"} + end + end; + _ -> {error, "unknown MCP configuration key"} + end; +from_json_map(_) -> + {error, "invalid MCP configuration"}. + +%% Optional fixed token (development convenience, erlang.mcp.authToken): when +%% absent the session gets a fresh random token. 16..128 URL-safe characters. +validate_token(T) when is_binary(T), byte_size(T) >= 16, byte_size(T) =< 128 -> + case re:run(T, "^[A-Za-z0-9._~-]+\\z", [{capture, none}]) of + match -> ok; + nomatch -> {error, "erlang.mcp.authToken may only contain letters, digits and ._~-"} + end; +validate_token(_) -> + {error, "erlang.mcp.authToken must be 16 to 128 characters"}. + +token_of(#{<<"authToken">> := T}) -> validate_token(T); +token_of(_) -> ok. + +%%------------------------------------------------------------------------------ +%% Helper VM entry point: erl -noshell -pa ebin -s mcp_policy cli +%% Reads one JSON line from stdin, prints one JSON line, halts. +%%------------------------------------------------------------------------------ + +cli() -> + Reply = try + catch io:setopts(standard_io, [{encoding, unicode}]), + case io:get_line("") of + Line when is_list(Line), length(Line) < 8192 -> + {ok, Input, _} = vscode_jsone_decode:decode(unicode:characters_to_binary(Line)), + case resolve(Input) of + disabled -> #{status => <<"disabled">>}; + {ok, Config} -> #{status => <<"ok">>, config => to_json_map(Config)}; + {error, Msg} -> #{status => <<"error">>, message => unicode:characters_to_binary(Msg)} + end; + _ -> + #{status => <<"error">>, message => <<"invalid MCP policy request">>} + end + catch + _:_ -> #{status => <<"error">>, message => <<"MCP policy could not be resolved">>} + end, + {ok, Json} = vscode_jsone:encode(Reply), + io:format("~s~n", [Json]), + init:stop(). + diff --git a/apps/erlangbridge/src/mcp/mcp_runtime.erl b/apps/erlangbridge/src/mcp/mcp_runtime.erl new file mode 100644 index 00000000..52fbb38e --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_runtime.erl @@ -0,0 +1,2102 @@ +%% Read-only runtime inspection behind the seven MCP tools. +%% +%% Everything here is metadata: no process dictionary, no sys:get_state, no +%% mailbox, no ETS keys/objects, no application callbacks, no code loading. +%% Callers run `call/3` inside a monitored, time-bounded worker (mcp_server). +%% +%% Mapping contract (schemaVersion 1.0): entities (application, process, +%% module, ets_table), typed relationships (depends_on, supervises, owns_table, +%% linked_to, monitors, belongs_to, uses_module) with evidence, observation +%% time and confidence, retained as a bounded *collection* and served in pages +%% through signed cursors. A collection is an observation interval, never an +%% atomic snapshot. +%% +%% Cost notes (documented residual risk): application:which_applications/1, +%% erlang:processes/0 (top_processes, capped at ?MAX_SCANNED entries), registered/0, supervisor:which_children/1 and supervisor:count_children/1 +%% materialize lists in the target VM and cannot be interrupted once started; +%% killing the worker only discards the late reply. +-module(mcp_runtime). + +-export([call/3, bounded/2, args_hash/1]). + +-define(SV, <<"1.0">>). +-define(MAX_LISTED_CHILDREN, 20000). +-define(MAX_SPEC_LOOKUPS, 200). +%% default page size for agents (detail=summary); detail=full pages use max_items +-define(SUMMARY_PAGE, 100). +-define(MAX_SCANNED, 200000). +-define(MAX_TOP, 50). +-define(MAX_ETS_SCAN, 50000). +-define(MAX_MAILBOX_COPY, 20000). +-define(MAX_GROUPS, 5000). +-define(MAX_SAMPLE, 20). + +%%------------------------------------------------------------------------------ +%% Entry point +%%------------------------------------------------------------------------------ + +%% Ctx: #{config, timeout_ms} +%% -> {ok, Structured} | {error, Code :: binary(), Message :: binary()} +call(Tool, Args, Ctx0) -> + Config = maps:get(config, Ctx0), + Timeout = maps:get(timeout_ms, Ctx0), + MaxItems = mcp_policy:limit(max_items, Config), + Detail = case maps:get(<<"detail">>, Args, <<"summary">>) of + <<"full">> -> full; + _ -> summary + end, + %% agents get small pages by default; detail=full is meant for exports + PageSize = case {maps:get(<<"pageSize">>, Args, undefined), Detail} of + {undefined, summary} -> min(?SUMMARY_PAGE, MaxItems); + {undefined, full} -> MaxItems; + {N, _} -> min(N, MaxItems) + end, + Format = case maps:get(<<"format">>, Args, <<"json">>) of + <<"mermaid">> -> mermaid; + _ -> json + end, + Ctx = Ctx0#{format => Format, + enc => maps:with([max_depth, max_items, max_binary_bytes], maps:get(limits, Config)), + soft_deadline => now_mono() + (Timeout * 7) div 10, + detail => Detail, page_size => PageSize}, + case maps:find(<<"cursor">>, Args) of + {ok, Cursor} -> page_from_cursor(Tool, Args, Cursor, Ctx); + error -> run(Tool, Args, Ctx) + end. + +run(<<"runtime_summary">>, Args, Ctx) -> runtime_summary(Args, Ctx); +run(<<"debug_session">>, _Args, Ctx) -> debug_session(Ctx); +run(<<"application_overview">>, Args, Ctx) -> graph_tool(<<"application_overview">>, builder(<<"application_overview">>), Args, Ctx); +run(<<"supervision_tree">>, Args, Ctx) -> graph_tool(<<"supervision_tree">>, builder(<<"supervision_tree">>), Args, Ctx); +run(<<"registered_processes">>, Args, Ctx) -> graph_tool(<<"registered_processes">>, builder(<<"registered_processes">>), Args, Ctx); +run(<<"process_info">>, Args, Ctx) -> tool_process_info(Args, Ctx); +run(<<"ets_tables">>, Args, Ctx) -> graph_tool(<<"ets_tables">>, builder(<<"ets_tables">>), Args, Ctx); +run(<<"process_state">>, Args, Ctx) -> process_state(Args, Ctx); +run(<<"process_groups">>, Args, Ctx) -> process_groups(Args, Ctx); +run(<<"mailbox_sample">>, Args, Ctx) -> mailbox_sample(Args, Ctx); +run(<<"ets_sample">>, Args, Ctx) -> ets_sample(Args, Ctx); +run(<<"top_ports">>, Args, Ctx) -> graph_tool(<<"top_ports">>, builder(<<"top_ports">>), Args, Ctx); +run(<<"ets_summary">>, Args, Ctx) -> graph_tool(<<"ets_summary">>, builder(<<"ets_summary">>), Args, Ctx); +run(<<"changes_since">>, Args, Ctx) -> graph_tool(<<"changes_since">>, builder(<<"changes_since">>), Args, Ctx); +run(<<"topology_overview">>, Args, Ctx) -> graph_tool(<<"topology_overview">>, builder(<<"topology_overview">>), Args, Ctx); +run(<<"top_processes">>, Args, Ctx) -> graph_tool(<<"top_processes">>, builder(<<"top_processes">>), Args, Ctx); +run(_, _, _) -> {error, <<"unknown_tool">>, <<"unknown tool">>}. + +builder(<<"application_overview">>) -> fun application_overview_build/2; +builder(<<"supervision_tree">>) -> fun supervision_tree_build/2; +builder(<<"registered_processes">>) -> fun registered_processes_build/2; +builder(<<"ets_tables">>) -> fun ets_tables_build/2; +builder(<<"top_processes">>) -> fun top_processes_build/2; +builder(<<"topology_overview">>) -> fun topology_overview_build/2; +builder(<<"changes_since">>) -> fun changes_since_build/2; +builder(<<"top_ports">>) -> fun top_ports_build/2; +builder(<<"ets_summary">>) -> fun ets_summary_build/2. + +%% Graph tools build a Builder (entities, relationships, omissions) and a scope; +%% finish_graph retains it as a collection and renders the first page. +graph_tool(Tool, Build, Args, Ctx) -> + Started = mcp_store:now_ms(), + case Build(Args, Ctx) of + {ok, Scope, B} -> finish_graph(Tool, Args, Scope, B, Started, Ctx); + {error, _, _} = E -> E + end. + +%% A deadline-bounded call in a monitored worker. On timeout the worker is +%% killed (an inspector-owned process) and any late reply is discarded. +%% -> {ok, Result} | timeout | {error, Reason} +bounded(Fun, Timeout) when Timeout > 0 -> + Parent = self(), + Ref = make_ref(), + {Pid, MRef} = spawn_monitor( + fun() -> + Res = try {ok, Fun()} catch C:R -> {error, {C, R}} end, + Parent ! {Ref, Res} + end), + receive + {Ref, Res} -> + erlang:demonitor(MRef, [flush]), + Res; + {'DOWN', MRef, process, Pid, Reason} -> + receive {Ref, Res} -> Res after 0 -> {error, {down, Reason}} end + after Timeout -> + exit(Pid, kill), + erlang:demonitor(MRef, [flush]), + receive {Ref, _} -> ok after 0 -> ok end, + timeout + end; +bounded(_, _) -> + timeout. + +now_mono() -> erlang:monotonic_time(millisecond). + +remaining(#{soft_deadline := D}) -> max(0, D - now_mono()). + +now_iso() -> mcp_encoder:iso8601(mcp_store:now_ms()). + +args_hash(Args) -> + Sorted = lists:sort(maps:to_list(Args)), + <> = crypto:hash(sha256, term_to_binary(Sorted)), + mcp_encoder:hex(H). + +%%------------------------------------------------------------------------------ +%% runtime_summary / debug_session (plain structured results) +%%------------------------------------------------------------------------------ + +runtime_summary(Args, _Ctx) -> + Redact = maps:get(<<"redactNodeHost">>, Args, true), + {Uptime, _} = erlang:statistics(wall_clock), + Mem = erlang:memory(), + {ok, #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"observedAt">> => now_iso(), + <<"otpRelease">> => list_to_binary(erlang:system_info(otp_release)), + <<"ertsVersion">> => list_to_binary(erlang:system_info(version)), + <<"node">> => node_text(Redact), + <<"nodeRedacted">> => Redact, + <<"uptimeMs">> => Uptime, + <<"schedulers">> => erlang:system_info(schedulers), + <<"processCount">> => erlang:system_info(process_count), + <<"connectedNodes">> => connected_nodes(Redact), + <<"schedulersOnline">> => erlang:system_info(schedulers_online), + <<"runQueue">> => erlang:statistics(run_queue), + <<"resources">> => #{<<"processes">> => resource(process_count, process_limit), + <<"atoms">> => resource(atom_count, atom_limit), + <<"ports">> => resource(port_count, port_limit), + <<"ets">> => resource(ets_count, ets_limit)}, + <<"memory">> => #{<<"unit">> => <<"bytes">>, + <<"total">> => proplists:get_value(total, Mem), + <<"code">> => proplists:get_value(code, Mem), + <<"processes">> => proplists:get_value(processes, Mem), + <<"system">> => proplists:get_value(system, Mem), + <<"atom">> => proplists:get_value(atom, Mem), + <<"binary">> => proplists:get_value(binary, Mem), + <<"ets">> => proplists:get_value(ets, Mem)}}}. + +%% Names of the visible connected nodes (hidden nodes such as the debugger helper are +%% not listed by nodes/0); the host part is redacted like the node's own name. +connected_nodes(Redact) -> + Nodes = nodes(), + #{<<"count">> => length(Nodes), + <<"names">> => [peer_text(N, Redact) || N <- lists:sublist(lists:sort(Nodes), 50)]}. + +peer_text(Node, false) -> atom_to_binary(Node, utf8); +peer_text(Node, true) -> + case binary:split(atom_to_binary(Node, utf8), <<"@">>) of + [Name, _Host] -> <">>; + [Name] -> Name + end. + +%% {count, limit, usedPercent}: how close the node is to a VM limit; absent +%% (null) on an OTP release that does not report it. +resource(CountKey, LimitKey) -> + try {erlang:system_info(CountKey), erlang:system_info(LimitKey)} of + {C, L} when is_integer(C), is_integer(L), L > 0 -> + #{<<"count">> => C, <<"limit">> => L, <<"usedPercent">> => round(C * 1000 / L) / 10}; + _ -> null + catch _:_ -> null + end. + +node_text(false) -> atom_to_binary(node(), utf8); +node_text(true) -> + case binary:split(atom_to_binary(node(), utf8), <<"@">>) of + [Name, _Host] -> <">>; + [Name] -> Name + end. + +%% Safe debugger metadata only: mode, connection state, interpreted modules and +%% breakpoint locations (module + line). Source and options are not returned. +debug_session(#{enc := Enc} = Ctx) -> + Max = maps:get(max_items, Enc), + %% three debugger calls share the request budget instead of 1.5 s each + Budget = max(100, min(1500, remaining(Ctx) div 3)), + {Interpreted, IUnavailable} = debug_call(fun() -> int:interpreted() end, [], Budget), + {Breaks, BUnavailable} = debug_call(fun() -> int:all_breaks() end, [], Budget), + {Snapshot, SUnavailable} = debug_call(fun() -> int:snapshot() end, [], Budget), + Paused = paused_list(Snapshot), + Mods = [atom_to_binary(M, utf8) || M <- lists:sublist(lists:sort(Interpreted), Max), is_atom(M)], + Bps = [#{<<"module">> => atom_to_binary(M, utf8), <<"line">> => L} + || {{M, L}, _} <- lists:sublist(lists:sort(Breaks), Max), is_atom(M), is_integer(L)], + {ok, #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"observedAt">> => now_iso(), + <<"mode">> => mode_text(mcp_store:mode()), + <<"nodeConnected">> => true, + <<"interpretedModules">> => Mods, + <<"breakpoints">> => Bps, + <<"pausedProcesses">> => [#{<<"id">> => pid_id(P), + <<"name">> => case erlang:process_info(P, registered_name) of + {registered_name, N} -> bin(N); + _ -> null + end, + <<"status">> => <<"break">>, + <<"module">> => atom_to_binary(M, utf8), + <<"line">> => L} + || {P, M, L} <- lists:sublist(Paused, Max)], + <<"debuggerMetadata">> => case IUnavailable orelse BUnavailable orelse SUnavailable of + true -> <<"unavailable">>; + false -> <<"available">> + end, + <<"truncated">> => length(Interpreted) > Max orelse length(Breaks) > Max + orelse length(Paused) > Max}}. + +debug_call(Fun, Default, Budget) -> + case bounded(Fun, Budget) of + {ok, R} when is_list(R) -> {R, false}; + _ -> {Default, true} + end. + +%% int:snapshot/0 -> [{Pid, Mod, Line}] of the interpreted processes stopped at a +%% breakpoint (status `break`). Only local pids and module/line are kept. +paused_list(Snapshot) -> + lists:sort([{P, M, L} || {P, _Init, break, {M, L}} <- Snapshot, + is_pid(P), node(P) =:= node(), is_atom(M), is_integer(L)]). + +%% -> {ok, #{Pid => {Mod, Line}}} | unavailable (debugger evidence only) +paused_map() -> + case bounded(fun() -> int:snapshot() end, 500) of + {ok, L} when is_list(L) -> {ok, maps:from_list([{P, {M, Line}} || {P, M, Line} <- paused_list(L)])}; + _ -> unavailable + end. + +paused_at(Pid) -> + case paused_map() of + {ok, M} -> maps:find(Pid, M); + unavailable -> error + end. + +mode_text(launch) -> <<"launch">>; +mode_text(attach) -> <<"attach">>; +mode_text(_) -> <<"unknown">>. + +%%------------------------------------------------------------------------------ +%% Builder: entities, relationships and omissions of one collection +%%------------------------------------------------------------------------------ + +new_b() -> #{ents => [], ent_ids => #{}, rels => [], rel_keys => #{}, oms => [], pids => #{}}. + +add_entity(#{ent_ids := Ids, ents := Es} = B, Id, Entity) -> + case maps:is_key(Id, Ids) of + true -> B; + false -> B#{ents := [Entity#{<<"id">> => Id} | Es], ent_ids := Ids#{Id => true}} + end. + +%% add fields to an entity already in the builder (add_entity keeps the first one) +merge_entity(#{ents := Es} = B, Id, Fields) -> + B#{ents := [case E of + #{<<"id">> := Id} -> maps:merge(E, Fields); + _ -> E + end || E <- Es]}. + +add_rel(#{rel_keys := Keys, rels := Rs} = B, Type, From, To, Evidence, Confidence) -> + Key = {Type, From, To}, + case maps:is_key(Key, Keys) of + true -> B; + false -> + R = #{<<"type">> => Type, <<"from">> => From, <<"to">> => To, + <<"evidence">> => Evidence, <<"observedAt">> => now_iso(), + <<"confidence">> => Confidence}, + B#{rel_keys := Keys#{Key => true}, rels := [R | Rs]} + end. + +add_omission(#{oms := Oms} = B, Reason, Count, Scope, Message, Resumable) -> + O0 = #{<<"reason">> => Reason, <<"count">> => Count, + <<"resumable">> => Resumable, <<"message">> => Message}, + O = case Scope of + undefined -> O0; + _ -> O0#{<<"scope">> => Scope} + end, + B#{oms := [O | Oms]}. + +note_pid(#{pids := P} = B, Pid) -> B#{pids := P#{Pid => true}}. + +%%------------------------------------------------------------------------------ +%% Collections and pages +%%------------------------------------------------------------------------------ + +finish_graph(Tool, Args, Scope, B, Started, Ctx) -> + Oms = lists:reverse(maps:get(oms, B)), + Truncated = lists:any(fun(#{<<"reason">> := R}) -> R =:= <<"limit_reached">> end, Oms), + Meta = #{scope => Scope, started_at => Started, finished_at => mcp_store:now_ms(), + complete => Oms =:= [], truncated => Truncated, omissions => Oms}, + Items = builder_items(B), + Hash = args_hash(maps:remove(<<"cursor">>, Args)), + {CollId, Meta1, Stored} = mcp_store:put_collection(Tool, Hash, Meta, Items, #{args => Args, detail => maps:get(detail, Ctx, summary)}), + render_page(Tool, Hash, CollId, Meta1, Stored, 0, Ctx). + +builder_items(B) -> + [{entity, E} || E <- lists:reverse(maps:get(ents, B))] + ++ [{relationship, R} || R <- lists:reverse(maps:get(rels, B))]. + +page_from_cursor(Tool, Args, Cursor, Ctx) -> + Hash = args_hash(maps:remove(<<"cursor">>, Args)), + case mcp_store:parse_cursor(Cursor, Tool, Hash) of + {error, _} -> + {error, <<"invalid_cursor">>, <<"the cursor is invalid for this tool, arguments or session">>}; + {ok, CollId, Offset} -> + case mcp_store:fetch_collection(CollId, Tool, Hash) of + {ok, #{meta := Meta, items := Items}} -> + render_page(Tool, Hash, CollId, Meta, Items, Offset, Ctx); + {error, cursor_expired} -> + {error, <<"cursor_expired">>, + <<"the collection is no longer retained; call the tool again (results are not silently restarted)">>} + end + end. + +render_page(Tool, Hash, CollId, Meta, Items, Offset, #{config := Config} = Ctx) -> + Rest = safe_nthtail(Offset, Items), + MaxItems = maps:get(page_size, Ctx), + MaxResult = mcp_policy:limit(max_result_bytes, Config), + fit_page(Tool, Hash, CollId, Meta, Rest, Offset, min(MaxItems, max(1, length(Rest))), MaxResult, Ctx). + +safe_nthtail(N, L) when N >= length(L) -> []; +safe_nthtail(N, L) -> lists:nthtail(N, L). + +fit_page(Tool, Hash, CollId, Meta, Rest, Offset, N, MaxResult, Ctx) -> + Page = lists:sublist(Rest, N), + Env = envelope(Tool, Hash, CollId, Meta, Page, Offset, length(Rest) > N, Ctx), + {ok, Json0} = vscode_jsone:encode(Env), + Json = iolist_to_binary(Json0), + %% the response carries the structured result twice: as structuredContent and + %% as escaped JSON text in content[0].text; plus the JSON-RPC framing + {ok, Text} = vscode_jsone:encode(Json), + case byte_size(Json) + iolist_size(Text) + 1024 =< MaxResult of + true -> {ok, Env}; + false when N > 1 -> + fit_page(Tool, Hash, CollId, Meta, Rest, Offset, N div 2, MaxResult, Ctx); + false -> + {error, <<"result_too_large">>, + <<"a single item exceeds max_result_bytes; the policy limits are too small for this node">>} + end. + +envelope(Tool, Hash, CollId, Meta, Page, Offset, More, Ctx) -> + Detail = maps:get(detail, Ctx, full), + Entities0 = [E || {entity, E} <- Page], + InPage = maps:from_list([{maps:get(<<"id">>, E), true} || E <- Entities0]), + Rels0 = [R#{<<"fromInPage">> => maps:is_key(maps:get(<<"from">>, R), InPage), + <<"toInPage">> => maps:is_key(maps:get(<<"to">>, R), InPage)} + || {relationship, R} <- Page], + {Entities, Rels, Scope} = + case Detail of + full -> {Entities0, Rels0, maps:get(scope, Meta)}; + summary -> {[compact_entity(E) || E <- Entities0], + [compact_rel(R) || R <- Rels0], + compact_scope(maps:get(scope, Meta), Offset)} + end, + Next = case More of + true -> mcp_store:make_cursor(CollId, Tool, Hash, Offset + length(Page)); + false -> null + end, + Env = #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"collectionId">> => CollId, + <<"startedAt">> => mcp_encoder:iso8601(maps:get(started_at, Meta)), + <<"finishedAt">> => mcp_encoder:iso8601(maps:get(finished_at, Meta)), + <<"scope">> => Scope, + <<"detail">> => atom_to_binary(Detail, utf8), + <<"entities">> => Entities, + <<"relationships">> => Rels, + <<"complete">> => maps:get(complete, Meta), + <<"truncated">> => maps:get(truncated, Meta), + <<"omissions">> => maps:get(omissions, Meta), + <<"nextCursor">> => Next, + <<"page">> => #{<<"offset">> => Offset, <<"items">> => length(Page)}}, + case maps:get(format, Ctx, json) of + mermaid -> Env#{<<"mermaid">> => mermaid_graph(Entities, Rels)}; + json -> Env + end. + +%% A `graph TD` of one page, from the entities and relationships it returns. Labels +%% are reduced to a safe character set (they come from atoms of the debugged VM). +mermaid_graph(Entities, Rels) -> + Known = maps:from_list([{maps:get(<<"id">>, E), E} || E <- Entities]), + Missing = lists:usort([Id || R <- Rels, Id <- [maps:get(<<"from">>, R), maps:get(<<"to">>, R)], + not maps:is_key(Id, Known)]), + Nodes = [mermaid_node(E) || E <- Entities] + ++ [[<<" ">>, mermaid_id(Id), <<"[\"">>, mermaid_text(Id), <<"\"]\n">>] || Id <- Missing], + Edges = [mermaid_edge(R) || R <- Rels], + iolist_to_binary([<<"graph TD\n">>, Nodes, Edges]). + +mermaid_node(E) -> + Label = [mermaid_text(mermaid_label(E)), + case mermaid_detail(E) of + <<>> -> []; + D -> [<<"
">>, mermaid_text(D)] + end], + [<<" ">>, mermaid_id(maps:get(<<"id">>, E)), <<"[\"">>, Label, <<"\"]\n">>]. + +mermaid_edge(#{<<"type">> := Type, <<"from">> := F, <<"to">> := T}) -> + Arrow = case Type of + <<"supervises">> -> <<"-->">>; + _ -> <<"-.->">> + end, + [<<" ">>, mermaid_id(F), <<" ">>, Arrow, <<"|">>, mermaid_text(Type), <<"| ">>, mermaid_id(T), <<"\n">>]. + +mermaid_label(E) -> + first_binary([maps:get(K, E, null) || K <- [<<"name">>, <<"driver">>, <<"childId">>, <<"pid">>, <<"id">>]]). + +mermaid_detail(E) -> + Parts = [V || K <- [<<"role">>, <<"restart">>, <<"childState">>], V <- [maps:get(K, E, null)], + is_binary(V), V =/= <<"unknown">>], + iolist_to_binary(lists:join(<<" / ">>, Parts)). + +first_binary([B | _]) when is_binary(B) -> B; +first_binary([_ | T]) -> first_binary(T); +first_binary([]) -> <<"?">>. + +mermaid_id(Id) -> [<<"n_">>, re:replace(Id, "[^A-Za-z0-9_]", "_", [global, {return, binary}])]. + +mermaid_text(Bin) -> + Clean = re:replace(Bin, "[^A-Za-z0-9_ :/.@#-]", "_", [global, {return, binary}]), + case byte_size(Clean) > 60 of + true -> <<(binary:part(Clean, 0, 57))/binary, "...">>; + false -> Clean + end. + +%% detail=summary: same entities and edges, without the fields an agent rarely +%% needs to reason about the topology (they stay available with detail=full). +compact_entity(E) -> + E1 = maps:without([<<"description">>, <<"pid">>, <<"specAvailable">>, <<"metadataAvailable">>], E), + maps:filter(fun(<<"alive">>, true) -> false; + (<<"application">>, null) -> false; + (<<"name">>, null) -> false; + (<<"includedApplications">>, []) -> false; + (<<"callbackModule">>, V) -> V =/= <<"unknown">> andalso V =/= null; + (<<"behaviour">>, <<"unknown">>) -> false; + (<<"modules">>, [M]) -> M =/= maps:get(<<"callbackModule">>, E, undefined); + (<<"childId">>, C) -> C =/= maps:get(<<"name">>, E, undefined); + (_, _) -> true + end, E1). + +%% the observation interval is the collection's startedAt/finishedAt +compact_rel(R) -> + maps:filter(fun(<<"observedAt">>, _) -> false; + (<<"fromInPage">>, true) -> false; + (<<"toInPage">>, true) -> false; + (_, _) -> true + end, R). + +%% coverage limitations are stated once, on the first page +compact_scope(Scope, 0) -> Scope; +compact_scope(Scope, _) -> maps:remove(<<"limitations">>, Scope). + +%%------------------------------------------------------------------------------ +%% Identities and small entity helpers +%%------------------------------------------------------------------------------ + +app_id(App) -> mcp_store:entity_id(application, {application, App}). +pid_id(Pid) -> mcp_store:entity_id(process, {process, Pid}). +module_id(Mod) -> mcp_store:entity_id(module, {module, Mod}). + +bin(A) when is_atom(A) -> atom_to_binary(A, utf8); +bin(L) when is_list(L) -> + case unicode:characters_to_binary(L) of + B when is_binary(B) -> B; + _ -> <<"">> + end; +bin(B) when is_binary(B) -> B. + +%% Modules that belong to the inspector are flagged, never presented as application processes. +inspector_name(Name) -> + lists:member(Name, [mcp_holder, mcp_sup, mcp_server, mcp_store]). + +-define(INSPECTOR_MODULES, [mcp_runtime, mcp_server, mcp_store, mcp_sup, mcp_audit, mcp_holder, + mcp_encoder, mcp_policy, mcp_tools]). + +%% A process of the inspector itself (registered name, or running one of its modules: +%% workers and connections are anonymous funs of those modules). +is_inspector_pid(Pid) -> + case erlang:process_info(Pid, [registered_name, initial_call, current_function]) of + undefined -> false; + Info -> inspector_info(Info) + end. + +inspector_info(Info) -> + Named = case lists:keyfind(registered_name, 1, Info) of + {registered_name, N} when is_atom(N) -> inspector_name(N); + _ -> false + end, + Named orelse inspector_module(proplists:get_value(initial_call, Info)) + orelse inspector_module(proplists:get_value(current_function, Info)). + +inspector_module({M, _, _}) -> lists:member(M, ?INSPECTOR_MODULES); +inspector_module(_) -> false. + +%% Safe check that a pid is a supervisor before any supervisor API is called on +%% it (calling which_children on a plain gen_server would crash it). +is_supervisor(Pid) -> + is_pid(Pid) andalso node(Pid) =:= node() andalso + try proc_lib:initial_call(Pid) of + {supervisor, _, _} -> true; + _ -> false + catch _:_ -> false + end. + +behaviours(Mod) when is_atom(Mod) -> + case erlang:module_loaded(Mod) of + true -> + try + [B || {Attr, Bs} <- Mod:module_info(attributes), + Attr =:= behaviour orelse Attr =:= behavior, + B <- Bs, is_atom(B)] + of + L -> [bin(B) || B <- L] + catch _:_ -> unknown + end; + false -> unknown + end; +behaviours(_) -> unknown. + +behaviours_field(Mod) -> + case behaviours(Mod) of + unknown -> <<"unknown">>; + L -> L + end. + +process_entity(Pid, Fields, B) -> + Id = pid_id(Pid), + Name = case erlang:process_info(Pid, registered_name) of + {registered_name, N} -> bin(N); + _ -> null + end, + E = maps:merge(#{<<"kind">> => <<"process">>, + <<"pid">> => mcp_encoder:pid_text(Pid), + <<"name">> => Name, + <<"alive">> => true}, Fields), + {Id, note_pid(add_entity(B, Id, E), Pid)}. + +%%------------------------------------------------------------------------------ +%% application_overview +%%------------------------------------------------------------------------------ + +started_apps(Ctx) -> + case bounded(fun() -> application:which_applications(1500) end, min(2000, max(1, remaining(Ctx)))) of + {ok, L} when is_list(L) -> {ok, lists:keysort(1, L)}; + timeout -> timeout; + _ -> error + end. + +%% -> {discovered, RootPid} | no_start_module | unavailable +root_info(App, Ctx) -> + case application:get_key(App, mod) of + {ok, {_Mod, _Args}} -> + Res = bounded(fun() -> + case application_controller:get_master(App) of + Master when is_pid(Master) -> + {Root, _} = application_master:get_child(Master), + Root; + _ -> undefined + end + end, min(1000, max(1, remaining(Ctx)))), + case Res of + {ok, Root} when is_pid(Root) -> {discovered, Root}; + _ -> unavailable + end; + _ -> no_start_module + end. + +application_overview_build(Args, #{enc := Enc} = Ctx) -> + IncludeModules0 = maps:get(<<"includeModules">>, Args, false), + %% listing the modules of every application floods an agent: with + %% detail=summary, modules are only listed for a selected application + NodeWide = not maps:is_key(<<"application">>, Args), + IncludeModules = IncludeModules0 andalso not (NodeWide andalso maps:get(detail, Ctx) =:= summary), + case select_application(maps:get(<<"application">>, Args, undefined)) of + {error, _, _} = E -> E; + {ok, Selected} -> + {Apps, B0} = case started_apps(Ctx) of + {ok, L} -> {L, new_b()}; + timeout -> + {[], add_omission(new_b(), <<"timeout">>, 1, undefined, + <<"application_controller did not answer in time">>, false)}; + error -> + {[], add_omission(new_b(), <<"unavailable_while_paused">>, 1, undefined, + <<"the started applications could not be listed">>, false)} + end, + Names = [A || {A, _, _} <- Apps], + Wanted = case Selected of + all -> Apps; + {app, Name} -> [T || {A, _, _} = T <- Apps, A =:= Name] + end, + B1 = case {Selected, Wanted} of + {{app, _}, []} -> + add_omission(B0, <<"disappeared">>, 1, undefined, + <<"the selected application is not started (anymore)">>, false); + _ -> B0 + end, + B2a = lists:foldl(fun({App, Desc, Vsn}, Acc) -> + app_entities(App, Desc, Vsn, Names, IncludeModules, Enc, Ctx, Acc) + end, B1, Wanted), + %% references to applications outside the listing (not started, or not selected): + %% added last so that a real entity is never shadowed by a stub + Refs = lists:usort(lists:append([case application:get_key(A, applications) of + {ok, D} when is_list(D) -> D; + _ -> [] + end || {A, _, _} <- Wanted])), + B2 = lists:foldl(fun(Dep, Acc) -> + Running = lists:member(Dep, Names), + add_entity(Acc, app_id(Dep), + #{<<"kind">> => <<"application">>, <<"name">> => bin(Dep), + <<"running">> => Running, <<"metadataAvailable">> => false}) + end, B2a, Refs), + B3 = case IncludeModules0 andalso not IncludeModules of + true -> add_omission(B2, <<"limit_reached">>, 1, undefined, + <<"modules are listed for one application at a time: pass application= (or detail=full)">>, + false); + false -> B2 + end, + Scope = #{<<"tool">> => <<"application_overview">>, + <<"application">> => case Selected of all -> null; {app, N} -> app_id(N) end, + <<"limitations">> => + [<<"Only started applications are listed; dependencies that are not started appear as running=false stubs.">>, + <<"Declared dependencies (depends_on) are declarations from the application resource, not evidence of runtime communication.">>, + <<"Root discovery uses the application master; rootStatus explains when no root is available.">>, + <<"Application environment values, code paths and callbacks are never read or invoked.">>]}, + {ok, Scope, B3} + end. + +select_application(undefined) -> {ok, all}; +select_application(Id) -> + case mcp_store:resolve_id(Id) of + {ok, {application, Name}} -> {ok, {app, Name}}; + {ok, _} -> {error, <<"invalid_id">>, <<"the id does not designate an application">>}; + expired -> {error, <<"unknown_or_expired_id">>, <<"unknown or expired entity id; call application_overview again">>} + end. + +app_entities(App, Desc, Vsn, _StartedNames, IncludeModules, Enc, Ctx, B0) -> + AppId = app_id(App), + Mods = case application:get_key(App, modules) of {ok, M} when is_list(M) -> M; _ -> unavailable end, + Deps = case application:get_key(App, applications) of {ok, D} when is_list(D) -> D; _ -> [] end, + Included = case application:get_key(App, included_applications) of {ok, I} when is_list(I) -> I; _ -> [] end, + StartMod = case application:get_key(App, mod) of {ok, {SM, _}} -> bin(SM); _ -> null end, + {RootStatus, Roots, B1} = case root_info(App, Ctx) of + {discovered, Root} -> + Kind = case is_supervisor(Root) of + true -> <<"supervisor">>; + false -> <<"unknown">> + end, + {RId, Bx} = process_entity(Root, #{<<"role">> => Kind, + <<"application">> => bin(App)}, B0), + mcp_store:note_member(Root, App, mcp_store:now_ms()), + Bx1 = add_rel(Bx, <<"belongs_to">>, RId, AppId, + <<"application master child">>, <<"confirmed">>), + {<<"discovered">>, [RId], Bx1}; + no_start_module -> {<<"no_start_module">>, [], B0}; + unavailable -> {<<"unavailable">>, [], B0} + end, + Entity = #{<<"kind">> => <<"application">>, + <<"name">> => bin(App), + <<"version">> => bin(Vsn), + <<"description">> => mcp_encoder:safe_binary(unicode:characters_to_binary(Desc), 256), + <<"running">> => true, + <<"startModule">> => StartMod, + <<"dependencies">> => [bin(D) || D <- Deps], + <<"includedApplications">> => [bin(D) || D <- Included], + <<"moduleCount">> => case Mods of unavailable -> null; _ -> length(Mods) end, + <<"rootStatus">> => RootStatus, + <<"roots">> => Roots}, + B2 = add_entity(B1, AppId, Entity), + B3 = lists:foldl( + fun(Dep, Acc) -> + add_rel(Acc, <<"depends_on">>, AppId, app_id(Dep), + <<"declared in the application resource (applications key)">>, <<"confirmed">>) + end, B2, Deps), + case {IncludeModules, Mods} of + {true, L} when is_list(L) -> + Max = maps:get(max_items, Enc), + Shown = lists:sublist(lists:sort(L), Max), + Bm = lists:foldl( + fun(Mod, Acc) -> + MId = module_id(Mod), + Acc1 = add_entity(Acc, MId, #{<<"kind">> => <<"module">>, <<"name">> => bin(Mod), + <<"loaded">> => erlang:module_loaded(Mod), + <<"behaviours">> => behaviours_field(Mod)}), + add_rel(Acc1, <<"belongs_to">>, MId, AppId, + <<"application resource modules key">>, <<"confirmed">>) + end, B3, Shown), + case length(L) > Max of + true -> add_omission(Bm, <<"limit_reached">>, length(L) - Max, AppId, + <<"module list truncated; use includeModules=false or a narrower scope">>, false); + false -> Bm + end; + _ -> B3 + end. + +%%------------------------------------------------------------------------------ +%% supervision_tree +%%------------------------------------------------------------------------------ + +supervision_tree_build(Args, #{config := Config} = Ctx) -> + MaxDepth = min(maps:get(<<"maxDepth">>, Args, 16), mcp_policy:limit(max_traversal_depth, Config)), + IncludeModules = maps:get(<<"includeModules">>, Args, false), + case tree_start(Args, Ctx) of + {error, _, _} = E -> E; + {ok, Roots, App, Scope0, B0} -> + Cfg = #{max_depth => MaxDepth, include_modules => IncludeModules, app => App, ctx => Ctx}, + B1 = walk([{R, 0} || R <- Roots], Cfg, #{}, B0), + Scope = Scope0#{<<"tool">> => <<"supervision_tree">>, <<"maxDepth">> => MaxDepth, + <<"limitations">> => + [<<"Only 'supervises' edges from supervisor:which_children/1 are emitted; links and monitors are never supervision.">>, + <<"Unsupervised, unregistered and transient processes are not part of this map.">>, + <<"Child start arguments are never returned.">>]}, + {ok, Scope, B1} + end. + +%% -> {ok, [RootPid], AppName | undefined, Scope, Builder} | {error, Code, Msg} +tree_start(#{<<"id">> := Id}, Ctx) -> + case mcp_store:resolve_id(Id) of + {ok, {application, App}} -> + case lists:keymember(App, 1, application:which_applications(1000)) of + false -> {error, <<"not_found">>, <<"the application is not started (anymore)">>}; + true -> + B0 = new_b(), + AppId = app_id(App), + case root_info(App, Ctx) of + {discovered, Root} -> + {ok, [Root], App, #{<<"application">> => AppId}, B0}; + no_start_module -> + {ok, [], App, #{<<"application">> => AppId}, + add_omission(B0, <<"unknown_membership">>, 1, AppId, + <<"library application without a start module: no root supervisor exists">>, false)}; + unavailable -> + {ok, [], App, #{<<"application">> => AppId}, + add_omission(B0, <<"timeout">>, 1, AppId, + <<"the root supervisor could not be discovered">>, false)} + end + end; + {ok, {process, Pid}} -> + start_from_pid(Pid, undefined); + {ok, _} -> + {error, <<"invalid_id">>, <<"the id designates neither an application nor a process">>}; + expired -> + {error, <<"unknown_or_expired_id">>, <<"unknown or expired entity id; call application_overview again">>} + end; +tree_start(#{<<"supervisor">> := Name}, _Ctx) -> + case resolve_process(Name) of + {ok, Pid} -> start_from_pid(Pid, undefined); + {error, _, _} = E -> E + end. + +start_from_pid(Pid, App) -> + case is_pid(Pid) andalso is_process_alive(Pid) of + false -> {error, <<"not_found">>, <<"the process does not exist (anymore)">>}; + true -> + case is_supervisor(Pid) of + false -> {error, <<"not_a_supervisor">>, <<"the process is not a supervisor">>}; + true -> {ok, [Pid], App, #{<<"process">> => pid_id(Pid)}, new_b()} + end + end. + +walk([], _Cfg, _Visited, B) -> B; +walk([{Sup, Depth} | Rest], #{ctx := Ctx} = Cfg, Visited, B) -> + case {maps:is_key(Sup, Visited), remaining(Ctx)} of + {true, _} -> + walk(Rest, Cfg, Visited, B); + {false, 0} -> + B1 = add_omission(B, <<"timeout">>, length(Rest) + 1, undefined, + <<"the time budget was exhausted before every supervisor was visited">>, false), + B1; + {false, _} -> + {SupId, B1} = process_entity(Sup, #{<<"role">> => <<"supervisor">>, + <<"application">> => app_text(Cfg)}, B), + case children_of(Sup, Ctx) of + {ok, Children, Counts} -> + B1c = merge_entity(B1, SupId, #{<<"children">> => counts_map(Counts)}), + {B2, Next} = lists:foldl( + fun(C, {Acc, Q}) -> child(C, Sup, SupId, Depth, Cfg, Acc, Q) end, + {B1c, []}, Children), + walk(Rest ++ lists:reverse(Next), Cfg, Visited#{Sup => true}, B2); + {too_many, N, Counts} -> + B1c = merge_entity(B1, SupId, #{<<"children">> => counts_map(Counts)}), + B2 = add_omission(B1c, <<"limit_reached">>, N, SupId, + <<"too many children to list safely; children not listed">>, false), + walk(Rest, Cfg, Visited#{Sup => true}, B2); + timeout -> + B2 = case paused_at(Sup) of + {ok, {PM, PL}} -> + add_omission(B1, <<"unavailable_while_paused">>, 1, SupId, + iolist_to_binary( + [<<"the supervisor is stopped at a breakpoint (">>, bin(PM), $:, + integer_to_binary(PL), + <<"); its children are missing from this map until it is resumed">>]), + false); + _ -> + add_omission(B1, <<"timeout">>, 1, SupId, + <<"the supervisor did not answer in time; its children are missing from this map">>, false) + end, + walk(Rest, Cfg, Visited#{Sup => true}, B2); + gone -> + B2 = add_omission(B1, <<"disappeared">>, 1, SupId, + <<"the supervisor exited during the collection">>, false), + walk(Rest, Cfg, Visited#{Sup => true}, B2) + end + end. + +app_text(#{app := undefined}) -> null; +app_text(#{app := App}) -> bin(App). + +%% -> {ok, [{ChildId, Child, Type, Modules, SpecMeta}], Counts} | {too_many, N, Counts} | timeout | gone +children_of(Sup, Ctx) -> + Fun = fun() -> + Counts = supervisor:count_children(Sup), + Total = proplists:get_value(specs, Counts, 0), + case Total > ?MAX_LISTED_CHILDREN of + true -> {too_many, Total, Counts}; + false -> + Cs = supervisor:which_children(Sup), + {ok, with_specs(Sup, Cs, 0, []), Counts} + end + end, + %% one third of the request budget per supervisor: a blocked supervisor + %% must not starve its siblings + PerCall = max(100, maps:get(timeout_ms, Ctx) div 3), + case bounded(Fun, min(remaining(Ctx), PerCall)) of + {ok, R} -> R; + timeout -> timeout; + {error, _} -> gone + end. + +%% supervisor:count_children/1 as an entity field: how many child specs, running +%% children, supervisors and workers (never child arguments). +counts_map(Counts) -> + maps:from_list([{atom_to_binary(K, utf8), V} + || K <- [specs, active, supervisors, workers], + {K2, V} <- Counts, K2 =:= K, is_integer(V)]). + +with_specs(_Sup, [], _N, Acc) -> lists:reverse(Acc); +with_specs(Sup, [{Id, Child, Type, Mods} | T], N, Acc) -> + Meta = case N < ?MAX_SPEC_LOOKUPS of + true -> spec_meta(Sup, Id, Child); + false -> unavailable + end, + with_specs(Sup, T, N + 1, [{Id, Child, Type, Mods, Meta} | Acc]); +with_specs(Sup, [_ | T], N, Acc) -> + with_specs(Sup, T, N, Acc). + +%% restart / shutdown / significant only - never the start MFA. +spec_meta(Sup, Id, Child) -> + Key = case Id of undefined when is_pid(Child) -> Child; _ -> Id end, + try supervisor:get_childspec(Sup, Key) of + {ok, Spec} when is_map(Spec) -> + #{restart => maps:get(restart, Spec, undefined), + shutdown => maps:get(shutdown, Spec, undefined), + significant => maps:get(significant, Spec, undefined)}; + {ok, Spec} when is_tuple(Spec), tuple_size(Spec) =:= 6 -> + #{restart => element(4, Spec), shutdown => element(5, Spec), significant => undefined}; + _ -> unavailable + catch _:_ -> unavailable + end. + +child({ChildId, Child, Type, Mods, Meta}, Sup, SupId, Depth, #{ctx := Ctx} = Cfg, B, Next) -> + Enc = maps:get(enc, Ctx), + Role = case Type of supervisor -> <<"supervisor">>; worker -> <<"worker">>; _ -> <<"unknown">> end, + ChildText = mcp_encoder:text(ChildId, Enc), + {ModNames, Callback} = module_names(Mods), + Common = #{<<"role">> => Role, + <<"childId">> => ChildText, + <<"modules">> => ModNames, + <<"callbackModule">> => Callback, + <<"behaviour">> => case Callback of + null -> <<"unknown">>; + _ -> behaviours_from_name(Mods) + end, + <<"application">> => app_text(Cfg)}, + Common1 = add_meta(Common, Meta), + {ChildEntId, B1} = + case Child of + P when is_pid(P) -> + case Cfg of + #{app := App} when App =/= undefined -> + mcp_store:note_member(P, App, mcp_store:now_ms()); + _ -> ok + end, + process_entity(P, Common1, B); + Other -> + State = case Other of restarting -> <<"restarting">>; _ -> <<"not_running">> end, + Id = mcp_store:entity_id(process, {slot, Sup, ChildText}), + E = Common1#{<<"kind">> => <<"process">>, <<"alive">> => false, + <<"childState">> => State, <<"pid">> => null, <<"name">> => null}, + {Id, add_entity(B, Id, E)} + end, + B2 = add_rel(B1, <<"supervises">>, SupId, ChildEntId, + <<"supervisor:which_children/1">>, <<"confirmed">>), + B3 = case maps:get(include_modules, Cfg) of + true -> module_edges(Mods, ChildEntId, B2); + false -> B2 + end, + Next1 = case {Child, Type} of + {P2, supervisor} when is_pid(P2) -> + case Depth + 1 < maps:get(max_depth, Cfg) of + true -> [{P2, Depth + 1} | Next]; + false -> Next + end; + _ -> Next + end, + B4 = case {Child, Type} of + {P3, supervisor} when is_pid(P3) -> + case Depth + 1 < maps:get(max_depth, Cfg) of + true -> B3; + false -> + add_omission(B3, <<"limit_reached">>, 1, ChildEntId, + <<"depth limit reached; call supervision_tree with this id to expand the subtree">>, + false) + end; + _ -> B3 + end, + {B4, Next1}. + +add_meta(M, unavailable) -> M#{<<"specAvailable">> => false}; +add_meta(M, #{restart := R, shutdown := S, significant := Sig}) -> + M1 = M#{<<"specAvailable">> => true, + <<"restart">> => spec_value(R), + <<"shutdown">> => spec_value(S)}, + case Sig of + undefined -> M1; + _ -> M1#{<<"significant">> => Sig =:= true} + end. + +spec_value(undefined) -> <<"unknown">>; +spec_value(V) when is_atom(V) -> bin(V); +spec_value(V) when is_integer(V) -> V; +spec_value(_) -> <<"unknown">>. + +module_names(dynamic) -> {<<"dynamic">>, null}; +module_names([M]) when is_atom(M) -> {[bin(M)], bin(M)}; +module_names(L) when is_list(L) -> {[bin(M) || M <- L, is_atom(M)], null}; +module_names(_) -> {<<"unknown">>, null}. + +behaviours_from_name([M]) when is_atom(M) -> behaviours_field(M); +behaviours_from_name(_) -> <<"unknown">>. + +module_edges(Mods, EntId, B) when is_list(Mods) -> + lists:foldl( + fun(M, Acc) when is_atom(M) -> + MId = module_id(M), + Acc1 = add_entity(Acc, MId, #{<<"kind">> => <<"module">>, <<"name">> => bin(M), + <<"loaded">> => erlang:module_loaded(M), + <<"behaviours">> => behaviours_field(M)}), + add_rel(Acc1, <<"uses_module">>, EntId, MId, + <<"supervisor child specification modules">>, <<"confirmed">>); + (_, Acc) -> Acc + end, B, Mods); +module_edges(_, _, B) -> B. + +%%------------------------------------------------------------------------------ +%% registered_processes +%%------------------------------------------------------------------------------ + +registered_processes_build(Args, #{enc := Enc, config := Config} = Ctx) -> + case select_application(maps:get(<<"application">>, Args, undefined)) of + {error, _, _} = E -> E; + {ok, Selected} -> + Cap = mcp_policy:limit(max_items, Config) * 4, + All = [N || N <- registered(), not inspector_name(N)], + Sorted = lists:sort(All), + Shown = lists:sublist(Sorted, Cap), + Masters = masters(Ctx), + {Confirmed, B0} = case Selected of + {app, App} -> cached_tree_pids(App, Ctx); + all -> {#{}, new_b()} + end, + {B1, Unknown} = lists:foldl( + fun(Name, {Acc, Unk}) -> + reg_entry(Name, Selected, Confirmed, Masters, Enc, Acc, Unk) + end, {B0, 0}, Shown), + B2 = case Unknown of + 0 -> B1; + _ -> add_omission(B1, <<"unknown_membership">>, Unknown, undefined, + <<"registered processes not attributable to the selected application were left out">>, + false) + end, + B3 = case length(Sorted) > Cap of + true -> add_omission(B2, <<"limit_reached">>, length(Sorted) - Cap, undefined, + <<"registered process list truncated; filter by application">>, false); + false -> B2 + end, + Scope = #{<<"tool">> => <<"registered_processes">>, + <<"application">> => case Selected of all -> null; {app, A} -> app_id(A) end, + <<"limitations">> => + [<<"Only locally registered names are listed; unregistered processes are not enumerated.">>, + <<"A registered name alone never confirms application membership.">>, + <<"Inspector processes are excluded.">>]}, + {ok, Scope, B3} + end. + +masters(Ctx) -> + case started_apps(Ctx) of + {ok, Apps} -> + lists:foldl( + fun({App, _, _}, Acc) -> + case catch application_controller:get_master(App) of + M when is_pid(M) -> Acc#{M => App}; + _ -> Acc + end + end, #{}, Apps); + _ -> #{} + end. + +cached_tree_pids(_App, #{tree_pids := Known}) -> {Known, new_b()}; +cached_tree_pids(App, Ctx) -> tree_pids(App, Ctx). + +%% pids found in an application's supervision tree (confirmed membership) +tree_pids(App, #{config := Config} = Ctx) -> + case root_info(App, Ctx) of + {discovered, Root} -> + Cfg = #{max_depth => mcp_policy:limit(max_traversal_depth, Config), + include_modules => false, app => App, ctx => Ctx}, + B = walk([{Root, 0}], Cfg, #{}, new_b()), + Pids = (maps:get(pids, B))#{Root => true}, + {maps:map(fun(_, _) -> App end, Pids), new_b_with_oms(B)}; + _ -> + {#{}, add_omission(new_b(), <<"unknown_membership">>, 1, app_id(App), + <<"no root supervisor is discoverable for this application">>, false)} + end. + +new_b_with_oms(B) -> (new_b())#{oms := maps:get(oms, B)}. + +reg_entry(Name, Selected, Confirmed, Masters, Enc, B, Unknown) -> + case whereis(Name) of + undefined -> {B, Unknown}; + Pid -> + {Conf, App, Evidence} = membership(Pid, Confirmed, Masters), + Keep = case Selected of + all -> true; + {app, Wanted} -> App =:= Wanted andalso Conf =/= <<"unknown">> + end, + case Keep of + false -> {B, Unknown + 1}; + true -> + Info = erlang:process_info(Pid, [status, message_queue_len]), + Fields = #{<<"role">> => <<"unknown">>, + <<"status">> => bin(proplists:get_value(status, Info, unknown)), + <<"messageQueueLen">> => proplists:get_value(message_queue_len, Info, 0), + <<"application">> => case App of undefined -> null; _ -> bin(App) end, + <<"membership">> => #{<<"confidence">> => Conf, <<"evidence">> => Evidence}}, + _ = Enc, + {_, B1} = process_entity(Pid, Fields, B), + {B1, Unknown} + end + end. + +%% -> {Confidence, App | undefined, Evidence} +membership(Pid, Confirmed, Masters) -> + case maps:find(Pid, Confirmed) of + {ok, App} -> + {<<"confirmed">>, App, <<"found in the supervision tree of the application">>}; + error -> + case mcp_store:member(Pid) of + {App, _} -> + {<<"confirmed">>, App, <<"seen in a supervision tree observed earlier in this session">>}; + _ -> + case erlang:process_info(Pid, group_leader) of + {group_leader, GL} -> + case maps:find(GL, Masters) of + {ok, App} -> + {<<"inferred">>, App, <<"group leader is the application master (not proof of membership)">>}; + error -> {<<"unknown">>, undefined, <<"no evidence">>} + end; + _ -> {<<"unknown">>, undefined, <<"no evidence">>} + end + end + end. + +%%------------------------------------------------------------------------------ +%% process_info +%%------------------------------------------------------------------------------ + +tool_process_info(Args, #{config := Config} = Ctx) -> + Started = mcp_store:now_ms(), + case resolve_target(Args) of + {error, _, _} = E -> E; + {ok, Pid} -> + case is_process_alive(Pid) of + false -> {error, <<"not_found">>, <<"the process does not exist (anymore)">>}; + true -> + Items = [status, current_function, initial_call, reductions, memory, + message_queue_len, links, monitors], + case erlang:process_info(Pid, Items) of + undefined -> {error, <<"not_found">>, <<"the process exited during the observation">>}; + Info -> + build_process_info(Pid, Info, Started, Args, Ctx, Config) + end + end + end. + +build_process_info(Pid, Info, Started, Args, #{enc := Enc} = Ctx, Config) -> + Max = mcp_policy:limit(max_items, Config), + {Conf, App, Evidence} = membership(Pid, #{}, masters(Ctx)), + Fields = #{<<"role">> => <<"unknown">>, + <<"status">> => bin(proplists:get_value(status, Info, unknown)), + <<"currentFunction">> => mfa_text(proplists:get_value(current_function, Info)), + <<"initialCall">> => mfa_text(proplists:get_value(initial_call, Info)), + <<"reductions">> => proplists:get_value(reductions, Info, 0), + <<"memory">> => proplists:get_value(memory, Info, 0), + <<"memoryUnit">> => <<"bytes">>, + <<"messageQueueLen">> => proplists:get_value(message_queue_len, Info, 0), + <<"application">> => case App of undefined -> null; _ -> bin(App) end, + <<"membership">> => #{<<"confidence">> => Conf, <<"evidence">> => Evidence}, + <<"callbackModule">> => <<"unknown">>, + <<"behaviour">> => <<"unknown">>}, + Fields1 = case paused_at(Pid) of + {ok, {PM, PL}} -> + Fields#{<<"debugger">> => #{<<"status">> => <<"break">>, + <<"module">> => bin(PM), <<"line">> => PL}}; + _ -> Fields + end, + {Id, B1} = process_entity(Pid, Fields1, new_b()), + Links = [L || L <- proplists:get_value(links, Info, []), is_pid(L) orelse is_port(L)], + Mons = [M || {process, M} <- proplists:get_value(monitors, Info, []), is_pid(M)], + {B2, Omitted1} = peers(Links, <<"linked_to">>, <<"process_info links">>, Id, Max, B1), + {B3, Omitted2} = peers(Mons, <<"monitors">>, <<"process_info monitors">>, Id, Max, B2), + B4 = case Omitted1 + Omitted2 of + 0 -> B3; + N -> add_omission(B3, <<"limit_reached">>, N, Id, + <<"links/monitors truncated to max_items">>, false) + end, + _ = Enc, + Scope = #{<<"tool">> => <<"process_info">>, <<"process">> => Id, + <<"limitations">> => + [<<"Links and monitors do not imply supervision or message traffic.">>, + <<"Callback module and behaviour are only reported when supervisor child metadata is available (see supervision_tree).">>, + <<"Process dictionary, stack, mailbox and state are never read.">>, + <<"The debugger field is only present for a process the debugger reports stopped at a breakpoint.">>]}, + finish_graph(<<"process_info">>, Args, Scope, B4, Started, Ctx). + +peers(Peers, Type, Evidence, FromId, Max, B) -> + {Shown, Rest} = case length(Peers) > Max of + true -> lists:split(Max, Peers); + false -> {Peers, []} + end, + B1 = lists:foldl( + fun(P, Acc) when is_pid(P), node(P) =:= node() -> + case is_process_alive(P) of + true -> + {PId, Acc1} = process_entity(P, #{<<"role">> => <<"unknown">>}, Acc), + add_rel(Acc1, Type, FromId, PId, Evidence, <<"confirmed">>); + false -> Acc + end; + (P, Acc) when is_port(P) -> + PId = mcp_store:entity_id(process, {port, P}), + Acc1 = add_entity(Acc, PId, #{<<"kind">> => <<"process">>, <<"role">> => <<"port">>, + <<"pid">> => null, <<"name">> => null, <<"alive">> => true}), + add_rel(Acc1, Type, FromId, PId, Evidence, <<"confirmed">>); + (_, Acc) -> Acc + end, B, Shown), + {B1, length(Rest)}. + +mfa_text({M, F, A}) when is_atom(M), is_atom(F), is_integer(A) -> + iolist_to_binary([atom_to_binary(M, utf8), ":", atom_to_binary(F, utf8), "/", integer_to_binary(A)]); +mfa_text(_) -> <<"unknown">>. + +resolve_target(#{<<"id">> := Id}) -> + case mcp_store:resolve_id(Id) of + {ok, {process, Pid}} when is_pid(Pid) -> {ok, Pid}; + {ok, _} -> {error, <<"invalid_id">>, <<"the id does not designate a live process">>}; + expired -> {error, <<"unknown_or_expired_id">>, <<"unknown or expired entity id">>} + end; +resolve_target(#{<<"name">> := Name}) -> resolve_process(Name); +resolve_target(#{<<"pid">> := Text}) -> resolve_process(Text). + +%% Registered name (existing atoms only) or local pid text. Never creates atoms. +resolve_process(<<"<", _/binary>> = Text) -> + case re:run(Text, "^<0\\.[0-9]{1,10}\\.[0-9]{1,10}>\\z", [{capture, none}]) of + match -> + try list_to_pid(binary_to_list(Text)) of + Pid when node(Pid) =:= node() -> {ok, Pid} + catch _:_ -> {error, <<"invalid_pid">>, <<"not a valid local pid">>} + end; + nomatch -> {error, <<"invalid_pid">>, <<"only local pids of this node are accepted">>} + end; +resolve_process(Name) when is_binary(Name) -> + try binary_to_existing_atom(Name, utf8) of + Atom -> + case whereis(Atom) of + undefined -> {error, <<"not_found">>, <<"no process is registered under this name">>}; + Pid -> {ok, Pid} + end + catch _:_ -> {error, <<"not_found">>, <<"no process is registered under this name">>} + end. + +%%------------------------------------------------------------------------------ +%% topology_overview: application + supervision tree + registered processes + +%% owned approved ETS tables of ONE application in a single collection. Each part is +%% included only when its own tool is allowed by the project policy. +%%------------------------------------------------------------------------------ + +topology_overview_build(Args, #{config := Config} = Ctx) -> + case overview_application(Args, Ctx) of + {error, _, _} = E -> E; + {ok, App} -> + AppId = app_id(App), + Allowed = fun(Tool) -> mcp_policy:tool_allowed(Tool, Config) end, + Part = fun(Tool, Build, PArgs, C) -> + case Allowed(Tool) of + true -> Build(PArgs, C); + false -> denied + end + end, + Tree = Part(<<"supervision_tree">>, fun supervision_tree_build/2, #{<<"id">> => AppId}, Ctx), + %% the registered-process part reuses the pids of the tree just walked + RegCtx = case Tree of + {ok, _, TB} -> Ctx#{tree_pids => maps:map(fun(_, _) -> App end, maps:get(pids, TB))}; + _ -> Ctx + end, + Over = Part(<<"application_overview">>, fun application_overview_build/2, #{<<"application">> => AppId}, Ctx), + Reg = Part(<<"registered_processes">>, fun registered_processes_build/2, #{<<"application">> => AppId}, RegCtx), + Ets = Part(<<"ets_tables">>, fun ets_tables_build/2, #{}, Ctx), + Parts = [{<<"supervision_tree">>, Tree}, {<<"application_overview">>, Over}, + {<<"registered_processes">>, Reg}, {<<"ets_tables">>, Ets}], + case [E || {_, {error, _, _} = E} <- Parts] of + [Err | _] -> Err; + [] -> merge_overview(AppId, Parts) + end + end. + +%% -> {ok, App} | {error, Code, Msg}: an entity id or the name of a started application +overview_application(#{<<"application">> := Id}, _Ctx) -> + case select_application(Id) of + {ok, {app, App}} -> {ok, App}; + {error, _, _} = E -> E + end; +overview_application(#{<<"name">> := Name}, Ctx) -> + try binary_to_existing_atom(Name, utf8) of + App -> + case started_apps(Ctx) of + {ok, Apps} -> + case lists:keymember(App, 1, Apps) of + true -> {ok, App}; + false -> {error, <<"not_found">>, <<"no started application has this name">>} + end; + _ -> {error, <<"timeout">>, <<"application_controller did not answer in time">>} + end + catch _:_ -> {error, <<"not_found">>, <<"no started application has this name">>} + end. + +%% The richest entity wins (the tree describes a supervisor better than the overview +%% does), so the tree is merged first; ETS tables only when their owner is in the map. +merge_overview(AppId, Parts) -> + Order = [<<"supervision_tree">>, <<"application_overview">>, <<"registered_processes">>], + Merged = lists:foldl( + fun(Tool, Acc) -> + case proplists:get_value(Tool, Parts) of + {ok, _, B} -> merge_builders(Acc, B); + _ -> Acc + end + end, new_b(), Order), + WithEts = case proplists:get_value(<<"ets_tables">>, Parts) of + {ok, _, EtsB} -> merge_owned_tables(Merged, EtsB); + _ -> Merged + end, + Denied = [Tool || {Tool, denied} <- Parts], + B1 = lists:foldl( + fun(Tool, Acc) -> + add_omission(Acc, <<"policy_denied">>, 1, undefined, + <<"part not included: the project policy does not allow ", Tool/binary>>, false) + end, WithEts, Denied), + Included = [Tool || {Tool, {ok, _, _}} <- Parts], + Limitations = lists:usort(lists:append([maps:get(<<"limitations">>, Sc, []) || {_, {ok, Sc, _}} <- Parts])), + Scope = #{<<"tool">> => <<"topology_overview">>, + <<"application">> => AppId, + <<"parts">> => Included, + <<"limitations">> => + [<<"One collection assembled from several observations made in sequence: it is an interval, not an atomic snapshot.">> + | Limitations]}, + {ok, Scope, B1}. + +merge_builders(Into, From) -> + B1 = lists:foldl(fun(E, Acc) -> add_entity(Acc, maps:get(<<"id">>, E), maps:remove(<<"id">>, E)) end, + Into, lists:reverse(maps:get(ents, From))), + B2 = lists:foldl(fun(#{<<"type">> := T, <<"from">> := F, <<"to">> := To} = R, #{rel_keys := Keys, rels := Rs} = Acc) -> + Key = {T, F, To}, + case maps:is_key(Key, Keys) of + true -> Acc; + false -> Acc#{rel_keys := Keys#{Key => true}, rels := [R | Rs]} + end + end, B1, lists:reverse(maps:get(rels, From))), + B3 = B2#{oms := maps:get(oms, B2) ++ maps:get(oms, From)}, + B3#{pids := maps:merge(maps:get(pids, B3), maps:get(pids, From))}. + +%% keep an approved table (and its owns_table edge) only when its owner is already in the map +merge_owned_tables(B, EtsB) -> + Have = maps:get(ent_ids, B), + Tables = maps:from_list([{maps:get(<<"id">>, E), E} || E <- maps:get(ents, EtsB), + maps:get(<<"kind">>, E) =:= <<"ets_table">>]), + Owned = [R || #{<<"type">> := <<"owns_table">>, <<"from">> := F, <<"to">> := T} = R <- lists:reverse(maps:get(rels, EtsB)), + maps:is_key(F, Have), maps:is_key(T, Tables)], + lists:foldl(fun(#{<<"to">> := T} = R, Acc) -> + Acc1 = add_entity(Acc, T, maps:remove(<<"id">>, maps:get(T, Tables))), + add_rel(Acc1, <<"owns_table">>, maps:get(<<"from">>, R), T, + maps:get(<<"evidence">>, R), maps:get(<<"confidence">>, R)) + end, B, Owned). + +%%------------------------------------------------------------------------------ +%% changes_since: what differs between a retained collection (the baseline) and a +%% fresh observation of the same tool and arguments. Identity is the entity id, so a +%% restarted process is `replaced` (same logical child, new id), never "the same". +%% Absence from a partial collection is not proof of termination: such removals are +%% marked `unconfirmed`. The baseline must still be retained (collection_ttl_ms). +%%------------------------------------------------------------------------------ + +-define(DIFFABLE, [<<"application_overview">>, <<"supervision_tree">>, <<"registered_processes">>, + <<"ets_tables">>, <<"top_processes">>, <<"topology_overview">>]). + +changes_since_build(Args, #{config := Config} = Ctx) -> + case mcp_store:fetch_collection_by_id(maps:get(<<"collectionId">>, Args)) of + {error, _} -> + {error, <<"baseline_expired">>, + <<"the baseline collection is unknown or no longer retained (collection_ttl_ms, max_collections); " + "call the tool again to get a new baseline">>}; + {ok, #{tool := OldTool, ctx := #{args := OldArgs} = OldCtx, meta := OldMeta, items := OldItems}} -> + case lists:member(OldTool, ?DIFFABLE) andalso mcp_policy:tool_allowed(OldTool, Config) of + false -> + {error, <<"invalid_baseline">>, <<"this collection cannot be used as a baseline">>}; + true -> + %% a fresh observation that is compared, not retained: only the diff takes a slot + %% observe again exactly as the baseline was observed (same detail) + case (builder(OldTool))(OldArgs, Ctx#{detail => maps:get(detail, OldCtx, summary)}) of + {ok, _Scope, NB} -> + Oms = lists:reverse(maps:get(oms, NB)), + NewMeta = #{complete => Oms =:= [], omissions => Oms}, + diff_collections(OldTool, maps:get(<<"collectionId">>, Args), + OldMeta, OldItems, NewMeta, builder_items(NB)); + {error, _, _} = E -> E + end + end; + {ok, _} -> + {error, <<"invalid_baseline">>, <<"this collection cannot be used as a baseline">>} + end. + +diff_collections(Tool, BaseId, OldMeta, OldItems, NewMeta, NewItems) -> + Old = [E || {entity, E} <- OldItems], + New = [E || {entity, E} <- NewItems], + OldIds = maps:from_list([{maps:get(<<"id">>, E), true} || E <- Old]), + NewIds = maps:from_list([{maps:get(<<"id">>, E), true} || E <- New]), + Removed0 = [E || E <- Old, not maps:is_key(maps:get(<<"id">>, E), NewIds)], + Added0 = [E || E <- New, not maps:is_key(maps:get(<<"id">>, E), OldIds)], + RemByKey = by_key(Removed0), + AddByKey = by_key(Added0), + %% a logical child that vanished once and appeared once under a new id was replaced + Pairs = [{R, A} || {Key, [R]} <- maps:to_list(RemByKey), {ok, [A]} <- [maps:find(Key, AddByKey)]], + ReplacedOld = maps:from_list([{maps:get(<<"id">>, R), true} || {R, _} <- Pairs]), + ReplacedNew = maps:from_list([{maps:get(<<"id">>, A), maps:get(<<"id">>, R)} || {R, A} <- Pairs]), + Removed = [E || E <- Removed0, not maps:is_key(maps:get(<<"id">>, E), ReplacedOld)], + Added = [E || E <- Added0, not maps:is_key(maps:get(<<"id">>, E), ReplacedNew)], + Absence = case maps:get(complete, NewMeta) of + true -> <<"observed">>; + false -> <<"unconfirmed">> + end, + Changed = [{A, #{<<"change">> => <<"added">>}} || A <- Added] + ++ [{A, #{<<"change">> => <<"replaced">>, <<"previousId">> => maps:get(maps:get(<<"id">>, A), ReplacedNew)}} + || {_, A} <- Pairs] + ++ [{R, #{<<"change">> => <<"removed">>, <<"absence">> => Absence}} || R <- Removed], + B0 = lists:foldl(fun({E, Mark}, Acc) -> + add_entity(Acc, maps:get(<<"id">>, E), maps:merge(maps:remove(<<"id">>, E), Mark)) + end, new_b(), Changed), + %% the edges of the fresh observation that lead to a new or replaced entity + Fresh = maps:from_list([{maps:get(<<"id">>, A), true} || A <- Added] ++ [{I, true} || I <- maps:keys(ReplacedNew)]), + B1 = lists:foldl( + fun({relationship, #{<<"type">> := T, <<"from">> := F, <<"to">> := To} = R}, Acc) -> + case maps:is_key(To, Fresh) of + true -> add_rel(Acc, T, F, To, maps:get(<<"evidence">>, R), maps:get(<<"confidence">>, R)); + false -> Acc + end; + (_, Acc) -> Acc + end, B0, NewItems), + Unchanged = length([E || E <- New, maps:is_key(maps:get(<<"id">>, E), OldIds)]), + B2 = B1#{oms := lists:reverse(maps:get(omissions, NewMeta, [])) ++ maps:get(oms, B1)}, + B3 = case maps:get(complete, OldMeta) of + true -> B2; + false -> add_omission(B2, <<"limit_reached">>, 1, undefined, + <<"the baseline collection was partial: entities reported as added may already have existed">>, + false) + end, + Scope = #{<<"tool">> => <<"changes_since">>, + <<"baselineCollectionId">> => BaseId, + <<"baselineTool">> => Tool, + <<"baselineFinishedAt">> => mcp_encoder:iso8601(maps:get(finished_at, OldMeta)), + <<"summary">> => #{<<"added">> => length(Added), <<"removed">> => length(Removed), + <<"replaced">> => length(Pairs), <<"unchanged">> => Unchanged}, + <<"limitations">> => + [<<"Compares two observation intervals, not atomic snapshots; the entities of this answer are only those that changed.">>, + <<"replaced = the same logical child (name or child id) now has a new id, i.e. it was restarted or recreated.">>, + <<"A removal from a partial collection is marked absence=unconfirmed: absence is not proof of termination.">>, + <<"The baseline must still be retained (collection_ttl_ms, max_collections).">>]}, + {ok, Scope, B3}. + +%% logical identity of a child across restarts: its registered name, else its child id; +%% only keys that are unique on their side can identify a replacement +by_key(Ents) -> + Keyed = [{K, E} || E <- Ents, K <- [change_key(E)], K =/= undefined], + lists:foldl(fun({K, E}, Acc) -> maps:update_with(K, fun(L) -> L ++ [E] end, [E], Acc) end, #{}, Keyed). + +change_key(#{<<"kind">> := Kind, <<"name">> := N}) when is_binary(N) -> {Kind, name, N}; +change_key(#{<<"kind">> := Kind, <<"childId">> := C}) when is_binary(C) -> {Kind, child, C}; +change_key(_) -> undefined. + +%%------------------------------------------------------------------------------ +%% Developer tier (never enabled by default; the project names them in allowed_tools): +%% bounded samples of application data. Values go through mcp_encoder: depth/item/size +%% limits, values of secret-looking keys replaced, credentials in URLs scrubbed. +%%------------------------------------------------------------------------------ + +%% A live, local, non-inspector process addressed by id, name or pid. +dev_target(Args) -> + case resolve_target(Args) of + {error, _, _} = E -> E; + {ok, Pid} -> + case is_process_alive(Pid) andalso not is_inspector_pid(Pid) of + true -> {ok, Pid}; + false -> {error, <<"not_found">>, <<"the process does not exist (anymore)">>} + end + end. + +dev_header(Pid) -> + #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"observedAt">> => now_iso(), + <<"process">> => pid_id(Pid), + <<"pid">> => mcp_encoder:pid_text(Pid), + <<"name">> => case erlang:process_info(Pid, registered_name) of + {registered_name, N} -> bin(N); + _ -> null + end}. + +%% sys:get_state/2 of a gen_server, gen_statem or gen_event (a system message is only sent to a +%% process whose callback module declares one of those behaviours; never a supervisor). +process_state(Args, #{enc := Enc} = Ctx) -> + case dev_target(Args) of + {error, _, _} = E -> E; + {ok, Pid} -> + case otp_behaviour(Pid) of + undefined -> + {error, <<"not_an_otp_process">>, + <<"the state is only read from gen_server, gen_statem and gen_event processes">>}; + {supervisor, _} -> + %% the state of a supervisor holds the child specifications, start arguments included + {error, <<"use_supervision_tree">>, + <<"the state of a supervisor contains child start arguments; use supervision_tree">>}; + {Behaviour, Callback} -> + T = max(100, min(2000, remaining(Ctx))), + case bounded(fun() -> sys:get_state(Pid, T) end, T + 200) of + {ok, State} -> + {ok, (dev_header(Pid))#{<<"behaviour">> => bin(Behaviour), + <<"callbackModule">> => bin(Callback), + <<"state">> => mcp_encoder:term(State, Enc), + <<"redaction">> => <<"best effort: values of keys named like a secret and credentials in URLs are replaced; a record without keys cannot be redacted">>}}; + timeout -> + {error, <<"timeout">>, + case paused_at(Pid) of + {ok, {M, L}} -> <<"the process is stopped at a breakpoint (", (bin(M))/binary, ":", + (integer_to_binary(L))/binary, ")">>; + _ -> <<"the process did not answer in time">> + end}; + {error, _} -> + {error, <<"unavailable">>, <<"the process exited or does not answer system messages">>} + end + end + end. + +%% {Behaviour, CallbackModule} | undefined +otp_behaviour(Pid) -> + case catch proc_lib:initial_call(Pid) of + {supervisor, Mod, _} when is_atom(Mod) -> {supervisor, Mod}; + {Mod, _, _} when is_atom(Mod) -> + case behaviours(Mod) of + L when is_list(L) -> + case [B || B <- [gen_server, gen_statem, gen_event, gen_fsm, supervisor], + lists:member(atom_to_binary(B, utf8), L)] of + [B | _] -> {B, Mod}; + [] -> undefined + end; + _ -> undefined + end; + _ -> undefined + end. + +%% The oldest messages of a mailbox (default 5, max 20), bounded and encoded. +mailbox_sample(Args, #{enc := Enc} = Ctx) -> + case dev_target(Args) of + {error, _, _} = E -> E; + {ok, Pid} -> + Limit = min(maps:get(<<"limit">>, Args, 5), ?MAX_SAMPLE), + Len = case erlang:process_info(Pid, message_queue_len) of + {message_queue_len, N} -> N; + _ -> 0 + end, + Head = (dev_header(Pid))#{<<"queueLength">> => Len, <<"order">> => <<"oldest first">>}, + case Len > ?MAX_MAILBOX_COPY of + true -> + {ok, Head#{<<"sample">> => [], <<"truncated">> => true, + <<"note">> => <<"the mailbox is too large to copy safely; only its length is reported">>}}; + false -> + T = max(100, min(2000, remaining(Ctx))), + case bounded(fun() -> erlang:process_info(Pid, messages) end, T) of + {ok, {messages, Msgs}} -> + {ok, Head#{<<"sample">> => [mcp_encoder:term(M, Enc) || M <- lists:sublist(Msgs, Limit)], + <<"truncated">> => length(Msgs) > Limit, + <<"redaction">> => <<"best effort: values of keys named like a secret and credentials in URLs are replaced">>}}; + timeout -> {error, <<"timeout">>, <<"the process did not answer in time">>}; + _ -> {error, <<"not_found">>, <<"the process exited during the observation">>} + end + end + end. + +%% A few objects of an approved named table (allowed_ets_tables), never of a private one. +ets_sample(Args, #{enc := Enc, config := Config} = Ctx) -> + Name = maps:get(<<"table">>, Args), + Limit = min(maps:get(<<"limit">>, Args, 5), ?MAX_SAMPLE), + case mcp_policy:ets_table_allowed(Name, Config) of + false -> {error, <<"policy_denied">>, <<"the table is not approved by allowed_ets_tables">>}; + true -> + Atom = try binary_to_existing_atom(Name, utf8) catch _:_ -> undefined end, + Prot = case Atom of undefined -> undefined; _ -> catch ets:info(Atom, protection) end, + case Prot of + P when P =:= public; P =:= protected -> + T = max(100, min(2000, remaining(Ctx))), + case bounded(fun() -> + Objs = case ets:match_object(Atom, '_', Limit) of + {O, _Cont} -> O; + '$end_of_table' -> [] + end, + {Objs, ets:info(Atom, size), ets:info(Atom, type)} + end, T) of + {ok, {Objects, Size, Type}} -> + {ok, #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"observedAt">> => now_iso(), + <<"table">> => Name, + <<"protection">> => bin(P), + <<"type">> => bin(Type), + <<"size">> => Size, + <<"sample">> => [mcp_encoder:term(O, Enc) || O <- Objects], + <<"truncated">> => Size > length(Objects), + <<"redaction">> => <<"best effort: values of keys named like a secret and credentials in URLs are replaced">>}}; + timeout -> {error, <<"timeout">>, <<"the table did not answer in time">>}; + _ -> {error, <<"not_found">>, <<"the table does not exist (anymore)">>} + end; + private -> {error, <<"policy_denied">>, <<"private tables are never read">>}; + _ -> {error, <<"not_found">>, <<"no table with this name exists">>} + end + end. + +%%------------------------------------------------------------------------------ +%% top_processes (ranking by queue length / reductions / memory) +%%------------------------------------------------------------------------------ + +top_processes_build(Args, Ctx) -> + SortBy = sort_item(maps:get(<<"sortBy">>, Args, <<"message_queue_len">>)), + Limit = min(maps:get(<<"limit">>, Args, 10), ?MAX_TOP), + All = erlang:processes(), + Total = length(All), + {Scan, Cut} = case Total > ?MAX_SCANNED of + true -> {lists:sublist(All, ?MAX_SCANNED), Total - ?MAX_SCANNED}; + false -> {All, 0} + end, + {Keyed, Unscanned} = rank_keys(Scan, SortBy, self(), Ctx, 0, []), + %% a few spare candidates: inspector processes are filtered out below + Ranked = lists:sublist(lists:reverse(lists:sort(Keyed)), Limit + 8), + Masters = masters(Ctx), + {B1, Gone, _} = lists:foldl( + fun({_, Pid}, {Acc, G, Rank}) when Rank =< Limit -> + case top_entry(Pid, SortBy, Rank, Masters, Acc) of + skip -> {Acc, G, Rank}; + gone -> {Acc, G + 1, Rank}; + {ok, Acc1} -> {Acc1, G, Rank + 1} + end; + (_, Done) -> Done + end, {new_b(), 0, 1}, Ranked), + B2 = case Gone of + 0 -> B1; + _ -> add_omission(B1, <<"disappeared">>, Gone, undefined, + <<"ranked processes that exited before they could be described">>, false) + end, + B3 = case Unscanned of + 0 -> B2; + _ -> add_omission(B2, <<"timeout">>, Unscanned, undefined, + <<"the time budget ended the scan; the ranking only covers the processes scanned so far">>, false) + end, + B4 = case Cut of + 0 -> B3; + _ -> add_omission(B3, <<"limit_reached">>, Cut, undefined, + <<"more processes than the scan limit; the ranking only covers the first ones">>, false) + end, + Scope = #{<<"tool">> => <<"top_processes">>, + <<"sortBy">> => atom_to_binary(SortBy, utf8), + <<"limit">> => Limit, + <<"processCount">> => Total, + <<"limitations">> => + [<<"A point-in-time scan, not an atomic snapshot: processes start and exit while it runs.">>, + <<"reductions are cumulative since the process started, not a rate; memory is in bytes.">>, + <<"Only metadata is returned: never mailbox, dictionary, stack or state.">>, + <<"Inspector processes are excluded.">>]}, + {ok, Scope, B4}. + +sort_item(<<"reductions">>) -> reductions; +sort_item(<<"memory">>) -> memory; +sort_item(_) -> message_queue_len. + +%% -> {[{Value, Pid}], NotScanned}: stops early when the time budget is used up +rank_keys([], _Item, _Self, _Ctx, _N, Acc) -> {Acc, 0}; +rank_keys([Pid | T] = Pids, Item, Self, Ctx, N, Acc) -> + case N rem 1000 =:= 0 andalso N > 0 andalso remaining(Ctx) =:= 0 of + true -> {Acc, length(Pids)}; + false -> + Acc1 = case Pid =/= Self andalso rank_value(Pid, Item) of + {Item, V} when is_integer(V) -> [{V, Pid} | Acc]; + _ -> Acc + end, + rank_keys(T, Item, Self, Ctx, N + 1, Acc1) + end. + +rank_value(Port, Item) when is_port(Port) -> erlang:port_info(Port, Item); +rank_value(Pid, Item) -> erlang:process_info(Pid, Item). + +top_entry(Pid, SortBy, Rank, Masters, B) -> + Items = [registered_name, status, current_function, initial_call, reductions, memory, message_queue_len], + case erlang:process_info(Pid, Items) of + undefined -> gone; + Info -> + case inspector_info(Info) of + true -> skip; + false -> top_entity(Pid, Info, SortBy, Rank, Masters, B) + end + end. + +top_entity(Pid, Info, SortBy, Rank, Masters, B) -> + {Conf, App, Evidence} = membership(Pid, #{}, Masters), + Fields = #{<<"role">> => <<"unknown">>, + <<"rank">> => Rank, + <<"rankedBy">> => atom_to_binary(SortBy, utf8), + <<"status">> => bin(proplists:get_value(status, Info, unknown)), + <<"currentFunction">> => mfa_text(proplists:get_value(current_function, Info)), + <<"initialCall">> => mfa_text(proplists:get_value(initial_call, Info)), + <<"reductions">> => proplists:get_value(reductions, Info, 0), + <<"memory">> => proplists:get_value(memory, Info, 0), + <<"memoryUnit">> => <<"bytes">>, + <<"messageQueueLen">> => proplists:get_value(message_queue_len, Info, 0), + <<"application">> => case App of undefined -> null; _ -> bin(App) end, + <<"membership">> => #{<<"confidence">> => Conf, <<"evidence">> => Evidence}}, + {_, B1} = process_entity(Pid, Fields, B), + {ok, B1}. + +%%------------------------------------------------------------------------------ +%% process_groups: every local process aggregated by its initial call. The scan keeps +%% one counter per group (never per-process data), so memory is O(groups) and the answer +%% is O(limit) whether the node has 400 or 400000 processes. Finds fan-out and leaks +%% (thousands of anonymous workers) that a top-N list of single processes never shows. +%%------------------------------------------------------------------------------ + +process_groups(Args, #{enc := Enc} = Ctx) -> + Started = mcp_store:now_ms(), + SortBy = case maps:get(<<"sortBy">>, Args, <<"count">>) of + <<"memory">> -> memory; + <<"reductions">> -> reductions; + <<"message_queue_len">> -> queue; + _ -> count + end, + Limit = min(maps:get(<<"limit">>, Args, 10), ?MAX_TOP), + All = erlang:processes(), + Total = length(All), + {Scan, Cut} = case Total > ?MAX_SCANNED of + true -> {lists:sublist(All, ?MAX_SCANNED), Total - ?MAX_SCANNED}; + false -> {All, 0} + end, + Masters = masters(Ctx), + Acc0 = #{groups => #{}, scanned => 0, registered => 0, memory => 0, other => 0}, + {Acc, Unscanned} = group_scan(Scan, self(), Masters, Ctx, 0, Acc0), + #{groups := Groups, scanned := Scanned, registered := Registered, memory := TotalMem, other := Other} = Acc, + Ranked = lists:sublist(lists:sort(fun(A, B) -> group_rank(SortBy, A) >= group_rank(SortBy, B) end, + [G#{key => K} || {K, G} <- maps:to_list(Groups)]), Limit), + Shown = [group_json(G, I, Scanned, TotalMem, Enc) || {I, G} <- lists:zip(lists:seq(1, length(Ranked)), Ranked)], + CoveredCount = lists:sum([maps:get(count, G) || G <- Ranked]), + Oms = [#{<<"reason">> => <<"timeout">>, <<"count">> => Unscanned, <<"resumable">> => false, + <<"message">> => <<"the time budget ended the scan; the groups only cover the processes scanned so far">>} + || Unscanned > 0] + ++ [#{<<"reason">> => <<"limit_reached">>, <<"count">> => Cut, <<"resumable">> => false, + <<"message">> => <<"more processes than the scan limit; the groups only cover the first ones">>} + || Cut > 0] + ++ [#{<<"reason">> => <<"limit_reached">>, <<"count">> => Other, <<"resumable">> => false, + <<"message">> => <<"more distinct initial calls than the group limit; the rest are counted in ">>} + || Other > 0], + {ok, #{<<"schemaVersion">> => ?SV, + <<"sessionId">> => mcp_store:session_id(), + <<"observedAt">> => mcp_encoder:iso8601(Started), + <<"sortBy">> => atom_to_binary(case SortBy of queue -> message_queue_len; _ -> SortBy end, utf8), + <<"totals">> => #{<<"processCount">> => Total, <<"scanned">> => Scanned, + <<"registered">> => Registered, <<"unregistered">> => Scanned - Registered, + <<"groups">> => maps:size(Groups), <<"groupsShown">> => length(Shown), + <<"shownCoverPercent">> => percent(CoveredCount, Scanned), + <<"memoryBytes">> => TotalMem}, + <<"groups">> => Shown, + <<"complete">> => Oms =:= [], + <<"truncated">> => Cut > 0 orelse Other > 0, + <<"omissions">> => Oms, + <<"limitations">> => + [<<"Grouped by initial call (the callback module's init for OTP processes): a group is where processes were started, not a dependency.">>, + <<"A point-in-time scan, not an atomic snapshot: processes start and exit while it runs.">>, + <<"reductions are cumulative since the process started; memory is in bytes.">>, + <<"Only metadata is read: never mailbox, dictionary contents, stack or state. Inspector processes are excluded.">>]}}. + +group_scan([], _Self, _Masters, _Ctx, _N, Acc) -> {Acc, 0}; +group_scan([Pid | T] = Pids, Self, Masters, Ctx, N, Acc) -> + case N rem 1000 =:= 0 andalso N > 0 andalso remaining(Ctx) =:= 0 of + true -> {Acc, length(Pids)}; + false -> + Acc1 = case Pid =/= Self andalso + erlang:process_info(Pid, [registered_name, initial_call, current_function, memory, + reductions, message_queue_len, group_leader]) of + Info when is_list(Info) -> + case inspector_info(Info) of + true -> Acc; + false -> group_add(Pid, Info, Masters, Acc) + end; + _ -> Acc + end, + group_scan(T, Self, Masters, Ctx, N + 1, Acc1) + end. + +group_add(Pid, Info, Masters, #{groups := Groups, scanned := S, registered := R, memory := M, other := O} = Acc) -> + Key0 = group_key(Pid, Info), + {Key, Cap} = case maps:is_key(Key0, Groups) orelse maps:size(Groups) < ?MAX_GROUPS of + true -> {Key0, 0}; + false -> {other, 1} + end, + Reg = case lists:keyfind(registered_name, 1, Info) of + {registered_name, N} when is_atom(N), N =/= [] -> 1; + _ -> 0 + end, + Mem = proplists:get_value(memory, Info, 0), + App = case maps:find(proplists:get_value(group_leader, Info), Masters) of + {ok, A} -> A; + error -> undefined + end, + G0 = maps:get(Key, Groups, #{count => 0, memory => 0, reductions => 0, queue => 0, registered => 0, + apps => #{}, samples => []}), + #{count := C, memory := GM, reductions := GR, queue := GQ, registered := GReg, apps := Apps, samples := Sm} = G0, + G1 = G0#{count := C + 1, memory := GM + Mem, + reductions := GR + proplists:get_value(reductions, Info, 0), + queue := GQ + proplists:get_value(message_queue_len, Info, 0), + registered := GReg + Reg, + apps := maps:update_with(App, fun(X) -> X + 1 end, 1, Apps), + samples := case length(Sm) < 3 of true -> Sm ++ [Pid]; false -> Sm end}, + Acc#{groups := Groups#{Key => G1}, scanned := S + 1, registered := R + Reg, memory := M + Mem, other := O + Cap}. + +%% {M, F, A} of where the process started; for gen_server and friends the callback module's +%% init (OTP 25+ reads the one dictionary entry, never the whole dictionary). +group_key(Pid, Info) -> + Dict = try erlang:process_info(Pid, {dictionary, '$initial_call'}) of + {{dictionary, '$initial_call'}, {M0, F0, A0}} when is_atom(M0), is_atom(F0), is_integer(A0) -> {M0, F0, A0}; + _ -> undefined + catch _:_ -> undefined + end, + case Dict of + undefined -> + case proplists:get_value(initial_call, Info) of + {M, F, A} -> {M, F, A}; + _ -> unknown + end; + _ -> Dict + end. + +group_rank(count, #{count := V}) -> V; +group_rank(memory, #{memory := V}) -> V; +group_rank(reductions, #{reductions := V}) -> V; +group_rank(queue, #{queue := V}) -> V. + +group_json(#{key := Key, count := C, memory := M, reductions := R, queue := Q, registered := Reg, + apps := Apps, samples := Sm}, Rank, Scanned, TotalMem, Enc) -> + {Text, Mod} = case Key of + {Mo, F, A} -> {iolist_to_binary([bin(Mo), ":", bin(F), "/", integer_to_binary(A)]), bin(Mo)}; + other -> {<<"">>, null}; + unknown -> {<<"">>, null} + end, + AppList = lists:sublist(lists:reverse(lists:keysort(2, [{A, N} || {A, N} <- maps:to_list(Apps), A =/= undefined])), 5), + Unattributed = maps:get(undefined, Apps, 0), + #{<<"rank">> => Rank, + <<"initialCall">> => mcp_encoder:safe_binary(Text, maps:get(max_binary_bytes, Enc)), + <<"module">> => Mod, + <<"count">> => C, + <<"countPercent">> => percent(C, Scanned), + <<"registered">> => Reg, + <<"unregistered">> => C - Reg, + <<"memoryBytes">> => M, + <<"memoryPercent">> => percent(M, TotalMem), + <<"reductions">> => R, + <<"messageQueueLen">> => Q, + <<"applications">> => [#{<<"name">> => bin(A), <<"count">> => N, + <<"confidence">> => <<"inferred">>} || {A, N} <- AppList], + <<"unattributed">> => Unattributed, + <<"samples">> => [pid_id(P) || P <- Sm]}. + +percent(_, 0) -> 0.0; +percent(N, Total) -> round(N * 1000 / Total) / 10. + +%%------------------------------------------------------------------------------ +%% top_ports: local ports ranked by queue size / bytes in / bytes out. Only the +%% driver name (never an address, command line or path), counters and the owner. +%%------------------------------------------------------------------------------ + +top_ports_build(Args, Ctx) -> + SortBy = case maps:get(<<"sortBy">>, Args, <<"queue_size">>) of + <<"input">> -> input; + <<"output">> -> output; + _ -> queue_size + end, + Limit = min(maps:get(<<"limit">>, Args, 10), ?MAX_TOP), + All = erlang:ports(), + Total = length(All), + {Scan, Cut} = case Total > ?MAX_SCANNED of + true -> {lists:sublist(All, ?MAX_SCANNED), Total - ?MAX_SCANNED}; + false -> {All, 0} + end, + {Keyed, Unscanned} = rank_keys(Scan, SortBy, self(), Ctx, 0, []), + Ranked = lists:sublist(lists:reverse(lists:sort(Keyed)), Limit), + {B1, Gone, _} = lists:foldl( + fun({_, Port}, {Acc, G, Rank}) -> + case port_entry(Port, SortBy, Rank, Acc) of + gone -> {Acc, G + 1, Rank}; + skip -> {Acc, G, Rank}; + {ok, Acc1} -> {Acc1, G, Rank + 1} + end + end, {new_b(), 0, 1}, Ranked), + B2 = scan_omissions(B1, Gone, Unscanned, Cut, <<"ports">>), + Scope = #{<<"tool">> => <<"top_ports">>, + <<"sortBy">> => atom_to_binary(SortBy, utf8), + <<"limit">> => Limit, + <<"portCount">> => Total, + <<"limitations">> => + [<<"A point-in-time scan, not an atomic snapshot: ports open and close while it runs.">>, + <<"Only the driver name, byte counters, queue size and the owner process are reported; addresses, command lines, paths and data are never read.">>, + <<"driver is for any port that is not a well-known VM driver (for example a spawned program: its name is its command line).">>, + <<"owns_port links a port to its connected process; it is not evidence of traffic.">>]}, + {ok, Scope, B2}. + +port_entry(Port, SortBy, Rank, B) -> + Items = [name, connected, input, output, queue_size, registered_name], + %% port_info/2 takes one item at a time; a closed port answers undefined + Info = [I || Item <- Items, I <- [erlang:port_info(Port, Item)], I =/= undefined], + case erlang:port_info(Port, name) of + undefined -> gone; + _ -> + PId = mcp_store:entity_id(process, {port, Port}), + Fields = #{<<"kind">> => <<"process">>, <<"role">> => <<"port">>, <<"pid">> => null, + <<"name">> => case lists:keyfind(registered_name, 1, Info) of + {registered_name, N} when is_atom(N) -> bin(N); + _ -> null + end, + <<"alive">> => true, + <<"rank">> => Rank, + <<"rankedBy">> => atom_to_binary(SortBy, utf8), + <<"driver">> => driver_name(proplists:get_value(name, Info)), + <<"input">> => proplists:get_value(input, Info, 0), + <<"output">> => proplists:get_value(output, Info, 0), + <<"queueSize">> => proplists:get_value(queue_size, Info, 0), + <<"bytesUnit">> => <<"bytes">>}, + case proplists:get_value(connected, Info) of + Owner0 when is_pid(Owner0) -> InspectorOwned = is_inspector_pid(Owner0); + _ -> InspectorOwned = false + end, + B1 = case InspectorOwned of + true -> B; + false -> add_entity(B, PId, Fields) + end, + case proplists:get_value(connected, Info) of + _ when InspectorOwned -> skip; + Owner when is_pid(Owner), node(Owner) =:= node() -> + {OwnerId, B2} = process_entity(Owner, #{<<"role">> => <<"unknown">>}, B1), + {ok, add_rel(B2, <<"owns_port">>, OwnerId, PId, <<"port_info connected">>, <<"confirmed">>)}; + _ -> {ok, B1} + end + end. + +%% Only well-known VM drivers are named: the "name" of a port opened with spawn/2 is its +%% command line, which can be any text (and look like a driver name). +driver_name(Name) when is_list(Name) -> + case lists:member(Name, ["tcp_inet", "udp_inet", "sctp_inet", "efile", "tty_sl", "forker", "fd", + "ram_file_drv", "zlib_drv", "inet_gethost", "spawn", "ssl_tls"]) of + true -> list_to_binary(Name); + false -> <<"">> + end; +driver_name(_) -> <<"">>. + +%% omissions shared by the scanning tools +scan_omissions(B, Gone, Unscanned, Cut, What) -> + B1 = case Gone of + 0 -> B; + _ -> add_omission(B, <<"disappeared">>, Gone, undefined, + <<"ranked ", What/binary, " that ended before they could be described">>, false) + end, + B2 = case Unscanned of + 0 -> B1; + _ -> add_omission(B1, <<"timeout">>, Unscanned, undefined, + <<"the time budget ended the scan; the ranking only covers the ", What/binary, + " scanned so far">>, false) + end, + case Cut of + 0 -> B2; + _ -> add_omission(B2, <<"limit_reached">>, Cut, undefined, + <<"more ", What/binary, " than the scan limit; the ranking only covers the first ones">>, false) + end. + +%%------------------------------------------------------------------------------ +%% ets_summary: ETS memory per owner process, without any table name, key or value +%%------------------------------------------------------------------------------ + +ets_summary_build(Args, #{config := Config} = Ctx) -> + Limit = min(maps:get(<<"limit">>, Args, 10), ?MAX_TOP), + All = ets:all(), + Total = length(All), + {Scan, Cut} = case Total > ?MAX_ETS_SCAN of + true -> {lists:sublist(All, ?MAX_ETS_SCAN), Total - ?MAX_ETS_SCAN}; + false -> {All, 0} + end, + {Owners, Unscanned, Excluded} = ets_owners(Scan, Config, Ctx, 0, #{}, 0), + WordSize = erlang:system_info(wordsize), + TotalWords = lists:sum([W || {_, W} <- maps:values(Owners)]), + Ranked = lists:sublist(lists:reverse(lists:sort([{W, C, O} || {O, {C, W}} <- maps:to_list(Owners)])), Limit), + Masters = masters(Ctx), + {B1, _} = lists:foldl( + fun({Words, Count, Owner}, {Acc, Rank}) -> + {Conf, App, Evidence} = membership(Owner, #{}, Masters), + Fields = #{<<"role">> => <<"unknown">>, + <<"rank">> => Rank, + <<"application">> => case App of undefined -> null; _ -> bin(App) end, + <<"membership">> => #{<<"confidence">> => Conf, <<"evidence">> => Evidence}, + <<"ets">> => #{<<"tables">> => Count, <<"memory">> => Words, + <<"memoryUnit">> => <<"words">>, + <<"memoryBytes">> => Words * WordSize}}, + {_, Acc1} = process_entity(Owner, Fields, Acc), + {Acc1, Rank + 1} + end, {new_b(), 1}, Ranked), + B2a = scan_omissions(B1, 0, Unscanned, Cut, <<"tables">>), + B2 = case Excluded of + 0 -> B2a; + _ -> add_omission(B2a, <<"policy_denied">>, Excluded, undefined, + <<"private tables and tables not approved by allowed_ets_tables are not counted">>, false) + end, + Scope = #{<<"tool">> => <<"ets_summary">>, + <<"limit">> => Limit, + <<"tableCount">> => Total - Excluded, + <<"ownerCount">> => maps:size(Owners), + <<"totalMemory">> => #{<<"words">> => TotalWords, <<"bytes">> => TotalWords * WordSize}, + <<"limitations">> => + [<<"Per-owner aggregates only: no table name, key, object or value is read or returned, and no table inventory is listed.">>, + <<"Only tables approved by allowed_ets_tables (exact names, or all) are counted; private tables never are.">>, + <<"A point-in-time scan, not an atomic snapshot; tables are created and deleted while it runs.">>, + <<"For the metadata of a specific table use ets_tables (approved names only).">>]}, + {ok, Scope, B2}. + +%% -> {#{OwnerPid => {Tables, Words}}, NotScanned, NotCounted} +ets_owners([], _Config, _Ctx, _N, Acc, Ex) -> {Acc, 0, Ex}; +ets_owners([Tab | T] = Tabs, Config, Ctx, N, Acc, Ex) -> + case N rem 500 =:= 0 andalso N > 0 andalso remaining(Ctx) =:= 0 of + true -> {Acc, length(Tabs), Ex}; + false -> + {Acc1, Ex1} = case ets_counted(Tab, Config) of + false -> {Acc, Ex + 1}; + true -> + try {ets:info(Tab, owner), ets:info(Tab, memory)} of + {Owner, Words} when is_pid(Owner), node(Owner) =:= node(), is_integer(Words) -> + case is_inspector_pid(Owner) of + true -> {Acc, Ex}; + false -> {maps:update_with(Owner, fun({C, W}) -> {C + 1, W + Words} end, + {1, Words}, Acc), Ex} + end; + _ -> {Acc, Ex} + catch _:_ -> {Acc, Ex} + end + end, + ets_owners(T, Config, Ctx, N + 1, Acc1, Ex1) + end. + +%% never a private table; approved by exact name, or every table when allowed_ets_tables is `all` +ets_counted(Tab, Config) -> + try ets:info(Tab, protection) of + private -> false; + undefined -> false; + _ -> + case maps:get(allowed_ets_tables, Config) of + all -> true; + Names -> + ets:info(Tab, named_table) =:= true andalso + lists:member(atom_to_binary(ets:info(Tab, name), utf8), Names) + end + catch _:_ -> false + end. + +%%------------------------------------------------------------------------------ +%% ets_tables (metadata of approved named tables only) +%%------------------------------------------------------------------------------ + +ets_tables_build(_Args, #{config := Config}) -> + Names = case maps:get(allowed_ets_tables, Config) of + all -> [atom_to_binary(N, utf8) || T <- ets:all(), is_atom(T), ets:info(T, named_table) =:= true, + N <- [ets:info(T, name)], is_atom(N)]; + L -> L + end, + {B, Denied, Gone} = lists:foldl( + fun(Name, {Acc, D, G}) -> ets_entry(Name, Acc, D, G) end, + {new_b(), 0, 0}, Names), + B1 = case Denied of + 0 -> B; + _ -> add_omission(B, <<"policy_denied">>, Denied, undefined, + <<"approved tables that are private are never inspected">>, false) + end, + B2 = case Gone of + 0 -> B1; + _ -> add_omission(B1, <<"disappeared">>, Gone, undefined, + <<"approved tables that do not exist (or no longer exist) on the node">>, false) + end, + Scope = #{<<"tool">> => <<"ets_tables">>, + <<"approvedTables">> => length(Names), + <<"limitations">> => + [<<"Only tables approved by allowed_ets_tables are inspected; no node-wide inventory exists.">>, + <<"Keys, objects and values are never read.">>, + <<"memory is reported in words with memoryBytes derived from the VM word size.">>]}, + {ok, Scope, B2}. + +ets_entry(NameBin, B, Denied, Gone) -> + Atom = try binary_to_existing_atom(NameBin, utf8) catch _:_ -> undefined end, + case Atom of + undefined -> {B, Denied, Gone + 1}; + _ -> + case catch ets:info(Atom, protection) of + undefined -> {B, Denied, Gone + 1}; + private -> {B, Denied + 1, Gone}; + Prot when Prot =:= public; Prot =:= protected -> + case ets_facts(Atom, Prot) of + undefined -> {B, Denied, Gone + 1}; + {Owner, Facts} -> + TabIdent = ets_identity(Atom, Owner), + TId = mcp_store:entity_id(ets_table, {ets, Atom, TabIdent}), + B1 = add_entity(B, TId, Facts#{<<"kind">> => <<"ets_table">>}), + {OwnerId, B2} = process_entity(Owner, #{<<"role">> => <<"unknown">>}, B1), + B3 = add_rel(B2, <<"owns_table">>, OwnerId, TId, <<"ets:info owner">>, <<"confirmed">>), + {B3, Denied, Gone} + end; + _ -> {B, Denied, Gone + 1} + end + end. + +ets_facts(Tab, Prot) -> + try + Owner = ets:info(Tab, owner), + Words = ets:info(Tab, memory), + true = is_pid(Owner) andalso is_integer(Words), + {Owner, #{<<"name">> => bin(Tab), + <<"protection">> => bin(Prot), + <<"type">> => bin(ets:info(Tab, type)), + <<"size">> => ets:info(Tab, size), + <<"memory">> => Words, + <<"memoryUnit">> => <<"words">>, + <<"memoryBytes">> => Words * erlang:system_info(wordsize), + <<"owner">> => pid_id(Owner)}} + catch _:_ -> undefined + end. + +%% A recreated table has a different identity even if its name is reused. +ets_identity(Tab, Owner) -> + try ets:info(Tab, id) of + Id when Id =/= undefined -> Id; + _ -> {Owner, ets:info(Tab, heir)} + catch _:_ -> {Owner, undefined} + end. diff --git a/apps/erlangbridge/src/mcp/mcp_server.erl b/apps/erlangbridge/src/mcp/mcp_server.erl new file mode 100644 index 00000000..6520a645 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_server.erl @@ -0,0 +1,540 @@ +%% Authenticated MCP endpoint (Streamable HTTP, JSON responses only) of the +%% embedded inspector. +%% +%% Pinned protocol revision : 2025-06-18. Stateless: no Mcp-Session-Id is +%% issued, no SSE stream (GET -> 405). Implemented methods: initialize, +%% notifications/* (202), ping, tools/list, tools/call, prompts/list and +%% prompts/get (one static, argument-checked prompt; it grants nothing). No resources. Batches are rejected (removed in this revision). +%% +%% Security: explicit loopback bind, Host/Origin validation, no CORS, bearer +%% token required on every request *before* the body is read, body size cap, +%% header/body/connection timeouts, bounded connections and concurrent tool +%% calls. Tokens and parameters are never logged. +-module(mcp_server). +-behaviour(gen_server). + +-export([start_link/2, info/0, renew/0]). +-export([format_status/1, format_status/2]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). +%% spawned processes +-export([acceptor/3, conn_main/2]). + +-define(PROTOCOL_VERSION, <<"2025-06-18">>). +-define(PATH, <<"/mcp">>). +-define(LEASE_MS, 8000). +-define(LEASE_CHECK_MS, 1000). +-define(IO_TIMEOUT_MS, 5000). +-define(CONN_LIFETIME_MS, 30000). +-define(MAX_HEADERS, 64). + +-record(state, {listen, port, host, token, config, lease_deadline, conns = #{}, max_conns}). + +start_link(Config, Token) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, {Config, Token}, []). + +info() -> gen_server:call(?MODULE, info). + +%% Lease renewal from the debug adapter. It runs in the server process, never in +%% an application process, so it keeps working while the application is stopped +%% at a breakpoint. +renew() -> gen_server:cast(?MODULE, renew). + +%%------------------------------------------------------------------------------ + +init({Config, Token}) -> + process_flag(trap_exit, true), + Host = maps:get(host, Config), + {ok, Ip} = inet:parse_strict_address(Host), + Family = case tuple_size(Ip) of 4 -> []; 8 -> [inet6] end, + Opts = Family ++ [binary, {active, false}, {reuseaddr, true}, {ip, Ip}, + {packet, raw}, {backlog, 16}, {send_timeout, ?IO_TIMEOUT_MS}, + {send_timeout_close, true}], + case gen_tcp:listen(maps:get(port, Config), Opts) of + {ok, L} -> + {ok, Port} = inet:port(L), + Max = mcp_policy:limit(max_concurrency, Config) * 4, + State = #state{listen = L, port = Port, host = Host, token = Token, config = Config, + lease_deadline = mono() + ?LEASE_MS, max_conns = Max}, + spawn_link(?MODULE, acceptor, [L, self(), Token]), + erlang:send_after(?LEASE_CHECK_MS, self(), lease_check), + mcp_audit:event(listening, #{port => Port}), + {ok, State}; + {error, Reason} -> + mcp_audit:event(listen_failed, #{reason => Reason}), + {stop, {listen_failed, Reason}} + end. + +handle_call(info, _, #state{host = Host, port = Port} = S) -> + {reply, #{host => Host, port => Port, path => ?PATH}, S}; +handle_call({conn, Pid}, _, #state{conns = Conns, max_conns = Max} = S) -> + case maps:size(Conns) >= Max of + true -> {reply, busy, S}; + false -> + Ref = erlang:monitor(process, Pid), + erlang:send_after(?CONN_LIFETIME_MS, self(), {conn_timeout, Pid}), + {reply, ok, S#state{conns = Conns#{Pid => Ref}}} + end; +handle_call(_, _, S) -> + {reply, {error, unsupported}, S}. + +handle_cast(renew, S) -> + {noreply, S#state{lease_deadline = mono() + ?LEASE_MS}}; +handle_cast(_, S) -> + {noreply, S}. + +handle_info(lease_check, #state{lease_deadline = D} = S) -> + case mono() > D of + true -> + mcp_audit:event(lease_expired, #{}), + {stop, lease_expired, S}; + false -> + erlang:send_after(?LEASE_CHECK_MS, self(), lease_check), + {noreply, S} + end; +handle_info({'DOWN', _, process, Pid, _}, #state{conns = Conns} = S) -> + {noreply, S#state{conns = maps:remove(Pid, Conns)}}; +handle_info({conn_timeout, Pid}, S) -> + exit(Pid, kill), + {noreply, S}; +handle_info({'EXIT', _Acceptor, Reason}, S) -> + {stop, {acceptor_exit, Reason}, S}; +handle_info(_, S) -> + {noreply, S}. + +%% a crash report or sys:get_status must never print the bearer token +format_status(Status) when is_map(Status) -> + maps:map(fun(state, #state{} = S) -> S#state{token = <<"">>}; + (_, V) -> V + end, Status). +format_status(_Opt, [_PDict, #state{} = S]) -> S#state{token = <<"">>}; +format_status(_Opt, [_PDict, S]) -> S. + +terminate(_, #state{listen = L, conns = Conns}) -> + catch gen_tcp:close(L), + [exit(Pid, kill) || Pid <- maps:keys(Conns)], + ok. + +mono() -> erlang:monotonic_time(millisecond). + +%%------------------------------------------------------------------------------ +%% Acceptor and connections +%%------------------------------------------------------------------------------ + +acceptor(L, Server, Token) -> + case gen_tcp:accept(L) of + {ok, Sock} -> + Pid = spawn(?MODULE, conn_main, [Sock, {Server, Token}]), + ok = gen_tcp:controlling_process(Sock, Pid), + Pid ! case gen_server:call(Server, {conn, Pid}) of + ok -> go; + busy -> busy + end, + acceptor(L, Server, Token); + {error, _} -> + exit(normal) + end. + +conn_main(Sock, {Server, Token}) -> + receive + go -> + try + handle_connection(Sock, Token) + catch + _:_ -> reply(Sock, 500, [], <<"{\"error\":\"internal error\"}">>) + end; + busy -> + reply(Sock, 503, [{<<"Retry-After">>, <<"1">>}], <<"{\"error\":\"too many connections\"}">>) + after ?IO_TIMEOUT_MS -> + ok + end, + _ = Server, + gen_tcp:close(Sock). + +handle_connection(Sock, Token) -> + Config = mcp_store:config(), + #{port := Port} = ?MODULE:info(), + Limits = maps:get(limits, Config), + MaxBody = maps:get(max_request_bytes, Limits), + ok = inet:setopts(Sock, [{packet, http_bin}, {packet_size, 8192}]), + case read_head(Sock) of + {ok, Method, Path, Headers} -> + case check_head(Method, Path, Headers, Port, Config, Token, MaxBody) of + {reject, Code, Extra, Body} -> reply(Sock, Code, Extra, Body); + {ok, Len} -> + ok = inet:setopts(Sock, [{packet, raw}]), + case read_body(Sock, Len) of + {ok, Body} -> serve(Sock, Body, Config); + error -> reply(Sock, 400, [], err_body(<<"incomplete request body">>)) + end + end; + error -> + reply(Sock, 400, [], err_body(<<"malformed request">>)) + end. + +read_head(Sock) -> + case gen_tcp:recv(Sock, 0, ?IO_TIMEOUT_MS) of + {ok, {http_request, Method, {abs_path, Path}, _}} -> + case read_headers(Sock, #{}, 0) of + {ok, Headers} -> {ok, Method, Path, Headers}; + error -> error + end; + _ -> error + end. + +read_headers(_, _, N) when N > ?MAX_HEADERS -> error; +read_headers(Sock, Acc, N) -> + case gen_tcp:recv(Sock, 0, ?IO_TIMEOUT_MS) of + {ok, http_eoh} -> {ok, Acc}; + {ok, {http_header, _, Field, _, Value}} -> + Name = header_name(Field), + case maps:is_key(Name, Acc) of + true when Name =:= <<"host">>; Name =:= <<"authorization">>; + Name =:= <<"content-length">>; Name =:= <<"origin">> -> + error; %% duplicated security-relevant header + _ -> read_headers(Sock, Acc#{Name => Value}, N + 1) + end; + _ -> error + end. + +header_name(A) when is_atom(A) -> lower(atom_to_binary(A, utf8)); +header_name(B) when is_binary(B) -> lower(B). + +lower(B) -> list_to_binary(string:to_lower(binary_to_list(B))). + +%% Cheap checks first; the bearer token before the body is read. +check_head(Method, Path, Headers, Port, Config, Token, MaxBody) -> + Checks = [fun() -> Path =:= ?PATH orelse {reject, 404, [], err_body(<<"not found">>)} end, + fun() -> Method =:= 'POST' orelse + {reject, 405, [{<<"Allow">>, <<"POST">>}], err_body(<<"method not allowed">>)} end, + fun() -> host_ok(maps:get(<<"host">>, Headers, undefined), Port, Config) orelse + {reject, 403, [], err_body(<<"forbidden host">>)} end, + fun() -> origin_ok(maps:get(<<"origin">>, Headers, undefined), Port, Config) orelse + {reject, 403, [], err_body(<<"forbidden origin">>)} end, + fun() -> auth_ok(maps:get(<<"authorization">>, Headers, undefined), Token) orelse + {reject, 401, [{<<"WWW-Authenticate">>, <<"Bearer">>}], err_body(<<"unauthorized">>)} end, + fun() -> content_type_ok(maps:get(<<"content-type">>, Headers, undefined)) orelse + {reject, 415, [], err_body(<<"content type must be application/json">>)} end, + fun() -> not maps:is_key(<<"transfer-encoding">>, Headers) orelse + {reject, 411, [], err_body(<<"content-length required">>)} end], + case run_checks(Checks) of + ok -> length_of(maps:get(<<"content-length">>, Headers, undefined), MaxBody); + Reject -> + mcp_audit:event(rejected, #{code => element(2, Reject)}), + journal(#{method => <<"http">>, status => integer_to_binary(element(2, Reject))}), + Reject + end. + +run_checks([]) -> ok; +run_checks([C | T]) -> + case C() of + true -> run_checks(T); + {reject, _, _, _} = R -> R + end. + +length_of(undefined, _) -> {reject, 411, [], err_body(<<"content-length required">>)}; +length_of(Bin, Max) -> + try binary_to_integer(Bin) of + N when N >= 0, N =< Max -> {ok, N}; + N when N > Max -> {reject, 413, [], err_body(<<"request too large">>)}; + _ -> {reject, 400, [], err_body(<<"invalid content-length">>)} + catch _:_ -> {reject, 400, [], err_body(<<"invalid content-length">>)} + end. + +allowed_hosts(Port, Config) -> + P = integer_to_binary(Port), + case inet:parse_strict_address(maps:get(host, Config)) of + {ok, {127, 0, 0, 1}} -> [<<"127.0.0.1:", P/binary>>, <<"localhost:", P/binary>>]; + {ok, {127, _, _, _} = Ip} -> [iolist_to_binary([inet:ntoa(Ip), ":", P])]; + {ok, _} -> [<<"[::1]:", P/binary>>, <<"localhost:", P/binary>>] + end. + +host_ok(undefined, _, _) -> false; +host_ok(Host, Port, Config) -> lists:member(lower(Host), allowed_hosts(Port, Config)). + +%% No Origin (non-browser client) is accepted; a browser Origin must itself be loopback. +origin_ok(undefined, _, _) -> true; +origin_ok(Origin, Port, Config) -> + lists:any(fun(H) -> lower(Origin) =:= <<"http://", H/binary>> end, allowed_hosts(Port, Config)). + +auth_ok(undefined, _) -> false; +auth_ok(Value, Token) -> + case binary:split(Value, <<" ">>) of + [Scheme, Presented] -> lower(Scheme) =:= <<"bearer">> andalso ct_eq(Presented, Token); + _ -> false + end. + +%% Timing-resistant comparison (length is not secret: tokens have a fixed size). +ct_eq(A, B) when byte_size(A) =:= byte_size(B) -> + ct_fold(binary_to_list(A), binary_to_list(B), 0) =:= 0; +ct_eq(_, _) -> false. + +ct_fold([], [], Acc) -> Acc; +ct_fold([X | Xs], [Y | Ys], Acc) -> ct_fold(Xs, Ys, Acc bor (X bxor Y)). + +content_type_ok(undefined) -> false; +content_type_ok(Value) -> + [Type | _] = binary:split(lower(Value), <<";">>), + trim(Type) =:= <<"application/json">>. + +trim(B) -> list_to_binary(string:strip(binary_to_list(B))). + +read_body(_Sock, 0) -> {ok, <<>>}; +read_body(Sock, Len) -> + case gen_tcp:recv(Sock, Len, ?IO_TIMEOUT_MS) of + {ok, Bin} -> {ok, Bin}; + _ -> error + end. + +%%------------------------------------------------------------------------------ +%% JSON-RPC +%%------------------------------------------------------------------------------ + +serve(Sock, Body, Config) -> + T0 = erlang:monotonic_time(millisecond), + {Code, Resp, Meta} = handle_rpc(Body, Config), + reply(Sock, Code, [], Resp), + journal(Meta#{durationMs => erlang:monotonic_time(millisecond) - T0, + bytes => byte_size(Resp)}). + +%% -> {HttpCode, ResponseBody, JournalMeta} +handle_rpc(Body, Config) -> + case decode(Body) of + {ok, #{<<"jsonrpc">> := <<"2.0">>, <<"method">> := Method} = Req} when is_binary(Method) -> + Id = maps:get(<<"id">>, Req, undefined), + Params = maps:get(<<"params">>, Req, #{}), + case {Id, is_map(Params)} of + {undefined, _} -> + %% notification (e.g. notifications/initialized, notifications/cancelled) + {202, <<>>, #{method => Method, status => <<"ok">>}}; + {_, false} -> + rpc(error_obj(Id, -32602, <<"params must be an object">>), Method, Params); + {_, true} when is_integer(Id); is_binary(Id) -> + rpc(dispatch(Method, Params, Id, Config), Method, Params); + _ -> + rpc(error_obj(null, -32600, <<"invalid request id">>), Method, #{}) + end; + {ok, L} when is_list(L) -> + rpc(error_obj(null, -32600, <<"batch requests are not supported">>), <<"batch">>, #{}); + {ok, _} -> + rpc(error_obj(null, -32600, <<"invalid request">>), <<"invalid">>, #{}); + error -> + {400, encode(error_obj(null, -32700, <<"parse error">>)), + #{method => <<"invalid">>, status => <<"-32700">>}} + end. + +rpc(Obj, Method, Params) -> + {200, encode(Obj), journal_meta(Method, Params, Obj)}. + +%% Request journal (see mcp_store:notify/1): request metadata only - never the +%% arguments, the result content or the token. The tool name is only kept when +%% it is a known tool, so a client cannot inject text in the journal. +journal_meta(Method, Params, Obj) -> + M0 = #{method => known_method(Method)}, + M1 = case Method of + <<"tools/call">> -> + Tool = maps:get(<<"name">>, Params, undefined), + Args = maps:get(<<"arguments">>, Params, #{}), + M0#{tool => case is_binary(Tool) andalso mcp_tools:known(Tool) of + true -> Tool; + false -> <<"?">> + end, + cursor => is_map(Args) andalso maps:is_key(<<"cursor">>, Args)}; + _ -> M0 + end, + maps:merge(M1, outcome(Obj)). + +known_method(M) -> + Known = [<<"initialize">>, <<"ping">>, <<"tools/list">>, <<"tools/call">>, + <<"prompts/list">>, <<"prompts/get">>, + <<"notifications/initialized">>, <<"notifications/cancelled">>], + case lists:member(M, Known) of + true -> M; + false -> <<"other">> + end. + +outcome(#{<<"error">> := #{<<"code">> := Code}}) -> + #{status => integer_to_binary(Code)}; +outcome(#{<<"result">> := #{<<"isError">> := true, <<"structuredContent">> := #{<<"error">> := #{<<"code">> := C}}}}) -> + #{status => C}; +outcome(#{<<"result">> := #{<<"structuredContent">> := S}}) when is_map(S) -> + Graph = case S of + #{<<"entities">> := E, <<"relationships">> := R} -> + #{entities => length(E), relationships => length(R), + omissions => length(maps:get(<<"omissions">>, S, [])), + complete => maps:get(<<"complete">>, S, true), + more => is_binary(maps:get(<<"nextCursor">>, S, null)), + offset => maps:get(<<"offset">>, maps:get(<<"page">>, S, #{}), 0)}; + _ -> #{} + end, + Graph#{status => <<"ok">>}; +outcome(_) -> + #{status => <<"ok">>}. + +journal(Meta) -> + catch mcp_store:notify(Meta), + ok. + +decode(Body) -> + try vscode_jsone_decode:decode(Body) of + {ok, Term, Rest} -> + case binary:replace(Rest, [<<" ">>, <<"\r">>, <<"\n">>, <<"\t">>], <<>>, [global]) of + <<>> -> {ok, Term}; + _ -> error + end; + _ -> error + catch _:_ -> error + end. + +encode(Term) -> + {ok, Json} = vscode_jsone:encode(Term), + iolist_to_binary(Json). + +error_obj(Id, Code, Message) -> + #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => Id, + <<"error">> => #{<<"code">> => Code, <<"message">> => Message}}. + +result_obj(Id, Result) -> + #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => Id, <<"result">> => Result}. + +dispatch(<<"initialize">>, Params, Id, Config) -> + case maps:get(<<"protocolVersion">>, Params, undefined) of + V when is_binary(V) -> + %% version negotiation: the server answers with the revision it implements + result_obj(Id, #{<<"protocolVersion">> => ?PROTOCOL_VERSION, + <<"capabilities">> => #{<<"tools">> => #{<<"listChanged">> => false}, + <<"prompts">> => #{<<"listChanged">> => false}}, + <<"serverInfo">> => #{<<"name">> => <<"erlang-otp-topology-inspector">>, + <<"title">> => <<"Erlang OTP topology inspector">>, + <<"version">> => <<"1.0.0">>}, + <<"instructions">> => + <<"Read-only structural map of the debugged Erlang node. Workflow: runtime_summary, " + "then application_overview (no modules), pick the application and follow its 'roots' " + "ids with supervision_tree (expand deeper subtrees with the ids given in 'limit_reached' " + "omissions), then process_info / registered_processes(application=id) / ets_tables / " + "debug_session only where needed; top_processes finds hot or stuck processes in one call. Ids are reusable across tools. Default detail=summary " + "keeps answers small; follow nextCursor only if you need the remaining items. Use " + "detail=full only when the user asks for the complete JSON (e.g. to save it to a file). " + "Maps are bounded observation intervals, not atomic snapshots: honour complete/" + "truncated/omissions. Structural edges are not message traffic.", + (disabled_note(Config))/binary>>}); + _ -> + error_obj(Id, -32602, <<"protocolVersion is required">>) + end; +dispatch(<<"ping">>, _, Id, _) -> + result_obj(Id, #{}); +dispatch(<<"tools/list">>, _, Id, Config) -> + result_obj(Id, #{<<"tools">> => mcp_tools:list(Config)}); +dispatch(<<"tools/call">>, Params, Id, Config) -> + tools_call(Params, Id, Config); +dispatch(<<"prompts/list">>, _, Id, _) -> + result_obj(Id, #{<<"prompts">> => mcp_tools:prompts()}); +dispatch(<<"prompts/get">>, Params, Id, Config) -> + case mcp_tools:prompt(maps:get(<<"name">>, Params, undefined), maps:get(<<"arguments">>, Params, #{}), Config) of + {ok, Result} -> result_obj(Id, Result); + {error, Msg} -> error_obj(Id, -32602, Msg) + end; +dispatch(_, _, Id, _) -> + error_obj(Id, -32601, <<"method not found">>). + +%% Tells the agent (and the user reading its explanation) why a tool is missing: the +%% project policy narrows the default set, or the developer tier is not enabled. +disabled_note(Config) -> + Allowed = fun(T) -> mcp_policy:tool_allowed(T, Config) end, + Off = [T || T <- mcp_policy:default_tools(), not Allowed(T)], + Dev = [T || T <- mcp_policy:all_tools() -- mcp_policy:default_tools(), not Allowed(T)], + Note1 = case Off of + [] -> <<>>; + _ -> iolist_to_binary([" Not available in this session (disabled by allowed_tools in the project's rebar.config; " + "use `default` there to follow the built-in set): ", lists:join(", ", Off), "."]) + end, + Note2 = case Dev of + [] -> <<>>; + _ -> iolist_to_binary([" Developer tools not enabled (name them in allowed_tools): ", lists:join(", ", Dev), "."]) + end, + <>. + +tools_call(Params, Id, Config) -> + Name = maps:get(<<"name">>, Params, undefined), + Args = maps:get(<<"arguments">>, Params, #{}), + Known = is_binary(Name) andalso mcp_tools:known(Name) andalso mcp_policy:tool_allowed(Name, Config), + case Known of + false -> + error_obj(Id, -32602, <<"unknown tool">>); + true -> + case mcp_tools:validate_args(Name, Args) of + {error, Msg} -> error_obj(Id, -32602, Msg); + ok -> run_tool(Name, Args, Id, Config) + end + end. + +run_tool(Name, Args, Id, Config) -> + case mcp_store:acquire_slot(self()) of + busy -> + mcp_audit:event(tool_call, #{tool => Name, status => busy}), + error_obj(Id, -32000, <<"inspector busy: too many concurrent requests, retry later">>); + ok -> + T0 = erlang:monotonic_time(millisecond), + Timeout = mcp_policy:limit(request_timeout_ms, Config), + Ctx = #{config => Config, timeout_ms => Timeout}, + Res = mcp_runtime:bounded(fun() -> mcp_runtime:call(Name, Args, Ctx) end, Timeout), + mcp_store:release_slot(self()), + {Status, Result} = tool_result(Res, Config), + mcp_audit:event(tool_call, #{tool => Name, status => Status, + duration_ms => erlang:monotonic_time(millisecond) - T0}), + result_obj(Id, Result) + end. + +tool_result({ok, {ok, Structured}}, Config) -> + Result = #{<<"content">> => [#{<<"type">> => <<"text">>, <<"text">> => encode(Structured)}], + <<"structuredContent">> => Structured, + <<"isError">> => false}, + Max = mcp_policy:limit(max_result_bytes, Config), + case byte_size(encode(result_obj(0, Result))) =< Max of + true -> {ok, Result}; + false -> {result_too_large, tool_error(<<"result_too_large">>, + <<"the result exceeds max_result_bytes; narrow the scope">>)} + end; +tool_result({ok, {error, Code, Message}}, _) -> + {error, tool_error(Code, Message)}; +tool_result(timeout, _) -> + {timeout, tool_error(<<"timeout">>, <<"the inspection exceeded the request time limit; narrow the scope or retry">>)}; +tool_result({error, _}, _) -> + %% no stack trace or internal term is exposed + {error, tool_error(<<"internal_error">>, <<"the inspection failed">>)}. + +tool_error(Code, Message) -> + Structured = #{<<"error">> => #{<<"code">> => Code, <<"message">> => Message}}, + #{<<"content">> => [#{<<"type">> => <<"text">>, <<"text">> => encode(Structured)}], + <<"structuredContent">> => Structured, + <<"isError">> => true}. + +err_body(Message) -> + encode(#{<<"error">> => Message}). + +%%------------------------------------------------------------------------------ +%% HTTP response +%%------------------------------------------------------------------------------ + +reply(Sock, Code, Extra, Body) -> + Headers = [{<<"Content-Type">>, <<"application/json">>}, + {<<"Content-Length">>, integer_to_binary(byte_size(Body))}, + {<<"Cache-Control">>, <<"no-store">>}, + {<<"Connection">>, <<"close">>} | Extra], + Head = [<<"HTTP/1.1 ">>, integer_to_binary(Code), $\s, reason(Code), <<"\r\n">>, + [[K, <<": ">>, V, <<"\r\n">>] || {K, V} <- Headers], <<"\r\n">>], + catch gen_tcp:send(Sock, [Head, Body]), + ok. + +reason(200) -> <<"OK">>; +reason(202) -> <<"Accepted">>; +reason(400) -> <<"Bad Request">>; +reason(401) -> <<"Unauthorized">>; +reason(403) -> <<"Forbidden">>; +reason(404) -> <<"Not Found">>; +reason(405) -> <<"Method Not Allowed">>; +reason(411) -> <<"Length Required">>; +reason(413) -> <<"Payload Too Large">>; +reason(415) -> <<"Unsupported Media Type">>; +reason(500) -> <<"Internal Server Error">>; +reason(503) -> <<"Service Unavailable">>; +reason(_) -> <<"Error">>. diff --git a/apps/erlangbridge/src/mcp/mcp_store.erl b/apps/erlangbridge/src/mcp/mcp_store.erl new file mode 100644 index 00000000..fbbbd8e3 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_store.erl @@ -0,0 +1,339 @@ +%% Per-session state of the MCP inspector: the session identity, bounded +%% entity-ID bookkeeping, retained collections (with TTL/count/byte budgets), +%% signed cursors, worker slots and the process-membership evidence. +%% +%% Everything here dies with the session (the process is a child of mcp_sup). +-module(mcp_store). +-behaviour(gen_server). + +-export([start_link/2, start_link/3, stop/0]). +-export([session_id/0, mode/0, config/0, started_at/0, now_ms/0]). +-export([entity_id/2, resolve_id/1]). +-export([put_collection/5, fetch_collection/3, fetch_collection_by_id/1, make_cursor/4, parse_cursor/3]). +-export([acquire_slot/1, release_slot/1]). +-export([note_member/3, member/1]). +-export([stats/0, notify/1]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). +-export([format_status/1, format_status/2]). + +-define(SERVER, ?MODULE). +-define(SWEEP_MS, 1000). +-define(MAX_MEMBERS, 5000). + +-record(state, { + config, + session_id, + mode, + started_at, + secret, %% signs cursors + max_ids, + ids_fwd = #{}, %% Key => Id + ids_rev = #{}, %% Id => Key + id_order = queue:new(), %% oldest first + collections = #{}, %% CollId => collection map + coll_order = [], %% newest first + slots = #{}, %% MonitorRef => Pid + notify, %% undefined | fun((map()) -> any()): request journal + seq = 0, %% MCP requests seen in this session + members = #{}, %% Pid => {AppName, ObservedAtMs} + member_order = queue:new() + }). + +start_link(Config, Mode) -> + start_link(Config, Mode, undefined). + +start_link(Config, Mode, Notify) -> + gen_server:start_link({local, ?SERVER}, ?MODULE, {Config, Mode, Notify}, []). + +stop() -> + catch gen_server:stop(?SERVER), + ok. + +session_id() -> gen_server:call(?SERVER, session_id). +mode() -> gen_server:call(?SERVER, mode). +config() -> gen_server:call(?SERVER, config). +started_at() -> gen_server:call(?SERVER, started_at). + +now_ms() -> erlang:system_time(millisecond). + +%% Stable id of an observed entity within the session. A restarted process or +%% a recreated table has a different Key and so a different id. +entity_id(Kind, Key) -> gen_server:call(?SERVER, {entity_id, Kind, Key}). + +%% -> {ok, Key} | expired +resolve_id(Id) when is_binary(Id) -> gen_server:call(?SERVER, {resolve_id, Id}); +resolve_id(_) -> expired. + +%% Meta: map (scope, startedAt, finishedAt, complete, truncated, omissions). +%% Items: [{entity, Map} | {relationship, Map}] in page order. +%% -> {CollId, Meta1, Stored}; items beyond the byte budget are dropped and the +%% collection is marked truncated with a non-resumable limit_reached omission. +put_collection(Tool, ArgsHash, Meta, Items, Ctx) -> + gen_server:call(?SERVER, {put_collection, Tool, ArgsHash, Meta, Items, Ctx}). + +%% -> {ok, Collection} | {error, cursor_expired} +fetch_collection(CollId, Tool, ArgsHash) -> + gen_server:call(?SERVER, {fetch_collection, CollId, Tool, ArgsHash}). + +%% A retained collection by its id alone (used to diff against a baseline). The id is +%% an unguessable per-session value; an expired or unknown id is {error, cursor_expired}. +fetch_collection_by_id(CollId) when is_binary(CollId) -> gen_server:call(?SERVER, {fetch_by_id, CollId}); +fetch_collection_by_id(_) -> {error, cursor_expired}. + +make_cursor(CollId, Tool, ArgsHash, Offset) -> + gen_server:call(?SERVER, {make_cursor, CollId, Tool, ArgsHash, Offset}). + +%% -> {ok, CollId, Offset} | {error, invalid_cursor} +parse_cursor(Cursor, Tool, ArgsHash) when is_binary(Cursor) -> + gen_server:call(?SERVER, {parse_cursor, Cursor, Tool, ArgsHash}); +parse_cursor(_, _, _) -> + {error, invalid_cursor}. + +%% -> ok | busy. The slot is released when Pid exits or calls release_slot/1. +acquire_slot(Pid) -> gen_server:call(?SERVER, {acquire_slot, Pid}). +release_slot(Pid) -> gen_server:cast(?SERVER, {release_slot, Pid}). + +note_member(Pid, App, At) -> gen_server:cast(?SERVER, {note_member, Pid, App, At}). +member(Pid) -> gen_server:call(?SERVER, {member, Pid}). + +stats() -> gen_server:call(?SERVER, stats). + +%% Journal of MCP requests: numbers the request and hands the (non-secret) +%% metadata map to the notify fun in a separate process, so a slow consumer +%% never delays a request. +notify(Meta) when is_map(Meta) -> gen_server:cast(?SERVER, {notify, Meta}). + +%%------------------------------------------------------------------------------ + +init({Config, Mode, Notify}) -> + Limits = maps:get(limits, Config), + Secret = crypto:strong_rand_bytes(32), + SessionId = <<"s_", (mcp_encoder:hex(crypto:strong_rand_bytes(8)))/binary>>, + erlang:send_after(?SWEEP_MS, self(), sweep), + {ok, #state{config = Config, session_id = SessionId, mode = Mode, notify = Notify, + started_at = now_ms(), secret = Secret, + max_ids = maps:get(max_items, Limits) * 10}}. + +handle_call(session_id, _, S) -> {reply, S#state.session_id, S}; +handle_call(mode, _, S) -> {reply, S#state.mode, S}; +handle_call(config, _, S) -> {reply, S#state.config, S}; +handle_call(started_at, _, S) -> {reply, S#state.started_at, S}; + +handle_call({entity_id, Kind, Key}, _, S) -> + {Id, S1} = do_entity_id(Kind, Key, S), + {reply, Id, S1}; +handle_call({resolve_id, Id}, _, S) -> + {reply, case maps:find(Id, S#state.ids_rev) of + {ok, Key} -> {ok, Key}; + error -> expired + end, S}; + +handle_call({put_collection, Tool, ArgsHash, Meta, Items, Ctx}, _, S) -> + {CollId, Meta1, Stored, S1} = do_put_collection(Tool, ArgsHash, Meta, Items, Ctx, sweep_collections(S)), + {reply, {CollId, Meta1, Stored}, S1}; +handle_call({fetch_collection, CollId, Tool, ArgsHash}, _, S0) -> + S = sweep_collections(S0), + Reply = case maps:find(CollId, S#state.collections) of + {ok, #{tool := Tool, args_hash := ArgsHash} = C} -> {ok, C}; + _ -> {error, cursor_expired} + end, + {reply, Reply, S}; + +handle_call({fetch_by_id, CollId}, _, S0) -> + S = sweep_collections(S0), + Reply = case maps:find(CollId, S#state.collections) of + {ok, C} -> {ok, C}; + error -> {error, cursor_expired} + end, + {reply, Reply, S}; +handle_call({make_cursor, CollId, Tool, ArgsHash, Offset}, _, S) -> + {reply, sign_cursor(CollId, Tool, ArgsHash, Offset, S), S}; +handle_call({parse_cursor, Cursor, Tool, ArgsHash}, _, S) -> + {reply, verify_cursor(Cursor, Tool, ArgsHash, S), S}; + +handle_call({acquire_slot, Pid}, _, #state{slots = Slots, config = Config} = S) -> + Max = mcp_policy:limit(max_concurrency, Config), + case maps:size(Slots) >= Max of + true -> {reply, busy, S}; + false -> + Ref = erlang:monitor(process, Pid), + {reply, ok, S#state{slots = Slots#{Ref => Pid}}} + end; + +handle_call({member, Pid}, _, S) -> + {reply, maps:get(Pid, S#state.members, undefined), S}; + +handle_call(stats, _, S) -> + {reply, #{ids => maps:size(S#state.ids_fwd), + collections => maps:size(S#state.collections), + collection_bytes => lists:sum([maps:get(bytes, C) || C <- maps:values(S#state.collections)]), + slots => maps:size(S#state.slots), + members => maps:size(S#state.members), + seq => S#state.seq}, S}; + +handle_call(_, _, S) -> {reply, {error, unsupported}, S}. + +handle_cast({release_slot, Pid}, #state{slots = Slots} = S) -> + Left = maps:filter(fun(Ref, P) when P =:= Pid -> erlang:demonitor(Ref, [flush]), false; + (_, _) -> true + end, Slots), + {noreply, S#state{slots = Left}}; +handle_cast({notify, Meta}, #state{notify = Notify, seq = Seq} = S) -> + N = Seq + 1, + case Notify of + F when is_function(F, 1) -> spawn(fun() -> catch F(Meta#{seq => N}) end); + _ -> ok + end, + {noreply, S#state{seq = N}}; +handle_cast({note_member, Pid, App, At}, S) -> + {noreply, do_note_member(Pid, App, At, S)}; +handle_cast(_, S) -> {noreply, S}. + +handle_info({'DOWN', Ref, process, _, _}, #state{slots = Slots} = S) -> + {noreply, S#state{slots = maps:remove(Ref, Slots)}}; +handle_info(sweep, S) -> + erlang:send_after(?SWEEP_MS, self(), sweep), + {noreply, sweep_collections(S)}; +handle_info(_, S) -> {noreply, S}. + +%% a crash report or sys:get_status must never print the cursor-signing secret +format_status(Status) when is_map(Status) -> + maps:map(fun(state, #state{} = S) -> S#state{secret = <<"">>}; + (_, V) -> V + end, Status). +format_status(_Opt, [_PDict, #state{} = S]) -> S#state{secret = <<"">>}; +format_status(_Opt, [_PDict, S]) -> S. + +terminate(_, _) -> ok. + +%%------------------------------------------------------------------------------ +%% Identities +%%------------------------------------------------------------------------------ + +do_entity_id(Kind, Key, #state{ids_fwd = Fwd} = S) -> + case maps:find(Key, Fwd) of + {ok, Id} -> {Id, S}; + error -> + Id = <<(prefix(Kind))/binary, (mcp_encoder:hex(crypto:strong_rand_bytes(8)))/binary>>, + S1 = evict_ids(S#state{ids_fwd = Fwd#{Key => Id}, + ids_rev = (S#state.ids_rev)#{Id => Key}, + id_order = queue:in(Key, S#state.id_order)}), + {Id, S1} + end. + +prefix(application) -> <<"a_">>; +prefix(process) -> <<"p_">>; +prefix(module) -> <<"m_">>; +prefix(ets_table) -> <<"t_">>. + +%% Bounded bookkeeping: the oldest handles expire (resolve_id/1 -> expired). +evict_ids(#state{ids_fwd = Fwd, max_ids = Max} = S) when map_size(Fwd) =< Max -> S; +evict_ids(#state{id_order = Q, ids_fwd = Fwd, ids_rev = Rev} = S) -> + case queue:out(Q) of + {{value, Key}, Q1} -> + Id = maps:get(Key, Fwd), + evict_ids(S#state{id_order = Q1, ids_fwd = maps:remove(Key, Fwd), ids_rev = maps:remove(Id, Rev)}); + {empty, _} -> S + end. + +do_note_member(Pid, App, At, #state{members = M, member_order = Q} = S) -> + case maps:is_key(Pid, M) of + true -> S#state{members = M#{Pid => {App, At}}}; + false -> + S1 = S#state{members = M#{Pid => {App, At}}, member_order = queue:in(Pid, Q)}, + trim_members(S1) + end. + +trim_members(#state{members = M} = S) when map_size(M) =< ?MAX_MEMBERS -> S; +trim_members(#state{members = M, member_order = Q} = S) -> + case queue:out(Q) of + {{value, Pid}, Q1} -> trim_members(S#state{members = maps:remove(Pid, M), member_order = Q1}); + {empty, _} -> S + end. + +%%------------------------------------------------------------------------------ +%% Collections +%%------------------------------------------------------------------------------ + +do_put_collection(Tool, ArgsHash, Meta, Items, Ctx, #state{config = Config} = S) -> + MaxBytes = mcp_policy:limit(max_collection_bytes, Config), + MaxColls = mcp_policy:limit(max_collections, Config), + {Stored, Dropped} = fit(Items, MaxBytes, 0, []), + CollId = <<"c_", (mcp_encoder:hex(crypto:strong_rand_bytes(6)))/binary>>, + Meta1 = case Dropped of + 0 -> Meta; + _ -> add_limit_omission(Meta, Dropped) + end, + Bytes = erlang:external_size(Stored), + Coll = #{tool => Tool, args_hash => ArgsHash, meta => Meta1, items => Stored, + bytes => Bytes, created => erlang:monotonic_time(millisecond), ctx => Ctx}, + Colls = (S#state.collections)#{CollId => Coll}, + S1 = evict_collections(S#state{collections = Colls, coll_order = [CollId | S#state.coll_order]}, MaxColls), + {CollId, Meta1, Stored, S1}. + +fit([], _, _, Acc) -> {lists:reverse(Acc), 0}; +fit([I | T] = All, Max, Used, Acc) -> + Size = erlang:external_size(I), + case Used + Size > Max of + true -> {lists:reverse(Acc), length(All)}; + false -> fit(T, Max, Used + Size, [I | Acc]) + end. + +add_limit_omission(Meta, Dropped) -> + Om = #{<<"reason">> => <<"limit_reached">>, + <<"count">> => Dropped, + <<"resumable">> => false, + <<"message">> => <<"collection byte budget reached; narrow the scope (application, subtree or depth)">>}, + Meta#{omissions => maps:get(omissions, Meta, []) ++ [Om], truncated => true, complete => false}. + +evict_collections(#state{coll_order = Order, collections = Colls} = S, Max) -> + {Keep, Drop} = case length(Order) > Max of + true -> lists:split(Max, Order); + false -> {Order, []} + end, + S#state{coll_order = Keep, collections = maps:without(Drop, Colls)}. + +sweep_collections(#state{collections = Colls, config = Config} = S) -> + Ttl = mcp_policy:limit(collection_ttl_ms, Config), + Now = erlang:monotonic_time(millisecond), + Alive = maps:filter(fun(_, #{created := C}) -> Now - C < Ttl end, Colls), + S#state{collections = Alive, + coll_order = [Id || Id <- S#state.coll_order, maps:is_key(Id, Alive)]}. + +%%------------------------------------------------------------------------------ +%% Cursors: c1..., bound to session, collection, +%% tool and arguments. +%%------------------------------------------------------------------------------ + +sign_cursor(CollId, Tool, ArgsHash, Offset, S) -> + Sig = signature(CollId, Tool, ArgsHash, Offset, S), + <<"c1.", CollId/binary, ".", (integer_to_binary(Offset))/binary, ".", Sig/binary>>. + +signature(CollId, Tool, ArgsHash, Offset, #state{secret = Secret, session_id = Sid}) -> + Data = [Secret, Sid, 0, CollId, 0, Tool, 0, ArgsHash, 0, integer_to_binary(Offset)], + <> = crypto:hash(sha256, Data), + mcp_encoder:hex(Head). + +verify_cursor(Cursor, Tool, ArgsHash, S) -> + case binary:split(Cursor, <<".">>, [global]) of + [<<"c1">>, CollId, OffBin, Sig] when byte_size(CollId) < 64, byte_size(OffBin) < 12 -> + try binary_to_integer(OffBin) of + Offset when Offset >= 0 -> + case constant_eq(Sig, signature(CollId, Tool, ArgsHash, Offset, S)) of + true -> {ok, CollId, Offset}; + false -> {error, invalid_cursor} + end; + _ -> {error, invalid_cursor} + catch _:_ -> {error, invalid_cursor} + end; + _ -> + {error, invalid_cursor} + end. + +constant_eq(A, B) when byte_size(A) =:= byte_size(B) -> + fold_xor(binary_to_list(A), binary_to_list(B), 0) =:= 0; +constant_eq(_, _) -> + false. + +fold_xor([], [], Acc) -> Acc; +fold_xor([X | Xs], [Y | Ys], Acc) -> fold_xor(Xs, Ys, Acc bor (X bxor Y)). diff --git a/apps/erlangbridge/src/mcp/mcp_sup.erl b/apps/erlangbridge/src/mcp/mcp_sup.erl new file mode 100644 index 00000000..7f469988 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_sup.erl @@ -0,0 +1,129 @@ +%% Dedicated supervisor of the embedded MCP inspector, plus the session API +%% used by the debugger bridge (vscode_connection). +%% +%% The subtree is started only on demand for a debug session and is completely +%% separate from the LSP supervision tree. It fails closed: any child exit +%% (listener failure, lease expiry, crash) shuts the whole subtree down; it is +%% never restarted with a different port or token. +%% +%% A `mcp_holder` process owns the supervisor link so that the subtree outlives +%% the (rpc) process that requested it. +-module(mcp_sup). +-behaviour(supervisor). + +-export([start_session/2, start_session/3, start_session/4, stop_session/0, renew/0, active/0]). +-export([init/1]). +%% spawned +-export([holder/6]). + +-define(START_TIMEOUT_MS, 5000). + +%% Config: normalized configuration (already validated by the caller and again here). +%% Mode: launch | attach +%% -> {ok, #{host, port, path, sessionId, token}} | {error, Reason :: binary()} +start_session(Config, Mode) -> + start_session(Config, Mode, undefined). + +%% FixedToken: undefined (random per session) or a validated developer token. +start_session(Config, Mode, FixedToken) -> + start_session(Config, Mode, FixedToken, #{}). + +%% Opts: #{notify => fun((map()) -> any())} - receives one non-secret metadata +%% map per MCP request (method, tool, status, duration, sizes); never arguments, +%% results or the token. +start_session(Config, Mode, FixedToken, Opts) -> + case mcp_policy:validate_config(Config) of + {error, Msg} -> {error, list_to_binary(Msg)}; + ok -> + case whereis(mcp_holder) of + undefined -> + Token = case FixedToken of undefined -> new_token(); _ -> FixedToken end, + Ref = make_ref(), + Caller = self(), + Holder = spawn(?MODULE, holder, [Caller, Ref, Config, Mode, Token, Opts]), + receive + {Ref, {ok, Info}} -> {ok, Info#{token => Token}}; + {Ref, {error, Reason}} -> {error, describe(Reason)} + after ?START_TIMEOUT_MS -> + exit(Holder, kill), + {error, <<"the MCP inspector did not start in time">>} + end; + _ -> + {error, <<"an MCP inspector is already running on this node">>} + end + end. + +stop_session() -> + case whereis(mcp_holder) of + undefined -> ok; + Pid -> + Ref = erlang:monitor(process, Pid), + Pid ! {stop, self()}, + receive {'DOWN', Ref, process, Pid, _} -> ok + after 3000 -> + exit(Pid, kill), + erlang:demonitor(Ref, [flush]), + ok + end + end. + +renew() -> + case whereis(mcp_server) of + undefined -> {error, not_running}; + _ -> mcp_server:renew(), ok + end. + +active() -> whereis(mcp_holder) =/= undefined. + +%%------------------------------------------------------------------------------ + +new_token() -> + B = base64:encode(crypto:strong_rand_bytes(32)), + << <<(url_safe(C))>> || <> <= B, C =/= $= >>. + +url_safe($+) -> $-; +url_safe($/) -> $_; +url_safe(C) -> C. + +describe({shutdown, {failed_to_start_child, mcp_server, {listen_failed, eaddrinuse}}}) -> + <<"the configured erlang.mcp.port is already in use; MCP is disabled for this session">>; +describe({shutdown, {failed_to_start_child, mcp_server, {listen_failed, Reason}}}) -> + iolist_to_binary(io_lib:format("the MCP listener cannot bind (~w)", [Reason])); +describe(_) -> + <<"the MCP inspector could not start">>. + +holder(Caller, Ref, Config, Mode, Token, Opts) -> + try register(mcp_holder, self()) of + true -> holder_start(Caller, Ref, Config, Mode, Token, Opts) + catch error:badarg -> + Caller ! {Ref, {error, already_started}} + end. + +holder_start(Caller, Ref, Config, Mode, Token, Opts) -> + process_flag(trap_exit, true), + case supervisor:start_link({local, ?MODULE}, ?MODULE, {Config, Mode, Token, maps:get(notify, Opts, undefined)}) of + {ok, Sup} -> + Info = mcp_server:info(), + Caller ! {Ref, {ok, Info#{sessionId => mcp_store:session_id()}}}, + holder_loop(Sup); + {error, Reason} -> + Caller ! {Ref, {error, Reason}} + end. + +holder_loop(Sup) -> + receive + {stop, _From} -> + exit(Sup, shutdown), + receive {'EXIT', Sup, _} -> ok after 3000 -> ok end; + {'EXIT', Sup, Reason} -> + mcp_audit:event(supervisor_exit, #{reason => Reason}); + _ -> + holder_loop(Sup) + end. + +init({Config, Mode, Token, Notify}) -> + Children = [#{id => mcp_store, start => {mcp_store, start_link, [Config, Mode, Notify]}, + restart => permanent, shutdown => 2000, type => worker, modules => [mcp_store]}, + #{id => mcp_server, start => {mcp_server, start_link, [Config, Token]}, + restart => permanent, shutdown => 2000, type => worker, modules => [mcp_server]}], + {ok, {#{strategy => one_for_all, intensity => 0, period => 1}, Children}}. diff --git a/apps/erlangbridge/src/mcp/mcp_tools.erl b/apps/erlangbridge/src/mcp/mcp_tools.erl new file mode 100644 index 00000000..a0d49a79 --- /dev/null +++ b/apps/erlangbridge/src/mcp/mcp_tools.erl @@ -0,0 +1,457 @@ +%% The eight read-only tools: descriptions, strict input schemas, output +%% schemas and the argument validator. Unknown arguments are rejected. +-module(mcp_tools). + +-export([list/1, known/1, validate_args/2, structured_error_schema/0]). +-export([prompts/0, prompt/3]). + +-define(SCHEMA_VERSION, <<"1.0">>). +-export([schema_version/0]). +schema_version() -> ?SCHEMA_VERSION. + +%% tools/list result entries for the allowed tools. +list(Config) -> + [tool_def(Name) || Name <- mcp_policy:all_tools(), mcp_policy:tool_allowed(Name, Config)]. + +known(Name) -> lists:member(Name, mcp_policy:all_tools()). + +tool_def(Name) -> + {Title, Desc, In, Out} = def(Name), + #{<<"name">> => Name, + <<"title">> => Title, + <<"description">> => Desc, + <<"inputSchema">> => In, + <<"outputSchema">> => Out, + <<"annotations">> => #{<<"readOnlyHint">> => true, + <<"destructiveHint">> => false, + <<"idempotentHint">> => true, + <<"openWorldHint">> => false}}. + +%%------------------------------------------------------------------------------ +%% Prompts: one static prompt that tells an agent how to use the tools. It carries +%% no data and grants nothing; the argument is checked before it is embedded. +%%------------------------------------------------------------------------------ + +prompts() -> + [#{<<"name">> => <<"map_application">>, + <<"title">> => <<"Map an OTP application">>, + <<"description">> => <<"Map the supervision topology of one application of the debugged node and report " + "coverage gaps and observations, using only what the inspector tools return.">>, + <<"arguments">> => [#{<<"name">> => <<"application">>, + <<"description">> => <<"Name of a started OTP application.">>, + <<"required">> => true}]}]. + +%% -> {ok, GetPromptResult} | {error, Message} +prompt(<<"map_application">>, Args, Config) when is_map(Args) -> + case maps:get(<<"application">>, Args, undefined) of + App when is_binary(App), byte_size(App) > 0, byte_size(App) =< 255 -> + case re:run(App, "^[A-Za-z0-9_@.-]+\\z", [{capture, none}]) of + match -> + {ok, #{<<"description">> => <<"Map the OTP topology of ", App/binary>>, + <<"messages">> => [#{<<"role">> => <<"user">>, + <<"content">> => #{<<"type">> => <<"text">>, + <<"text">> => map_application_text(App, Config)}}]}}; + nomatch -> {error, <<"invalid argument 'application': not an application name">>} + end; + undefined -> {error, <<"missing required argument 'application'">>}; + _ -> {error, <<"invalid argument 'application'">>} + end; +prompt(<<"map_application">>, _, _) -> + {error, <<"arguments must be an object">>}; +prompt(_, _, _) -> + {error, <<"unknown prompt">>}. + +map_application_text(App, Config) -> + Allowed = fun(Tool) -> mcp_policy:tool_allowed(Tool, Config) end, + Steps = case Allowed(<<"topology_overview">>) of + true -> + ["1. topology_overview(name=\"", App, "\"): the application, its supervision tree, registered " + "processes and owned approved ETS tables in one call. If an omission says limit_reached or " + "timeout, expand that subtree with supervision_tree and the id it gives.\n"]; + false -> + ["1. runtime_summary, then application_overview to find the id and root supervisors of \"", App, + "\"; supervision_tree from each root (expand subtrees named in limit_reached or timeout " + "omissions); registered_processes(application=) for registered workers outside the tree.\n"] + end, + iolist_to_binary( + ["Using the erlang-otp-topology-inspector MCP server, map the OTP topology of application ", App, + " in the node I am debugging. Use only the tools that tools/list returns.\n\nSteps:\n", Steps, + "2. top_processes (or process_info) only for processes that matter: supervisors, registered workers, " + "anything with a non-zero message queue. If debug_session reports processes stopped at a breakpoint, " + "say so: their supervisors may be reported as unavailable_while_paused.\n" + "3. ets_tables and debug_session only if they relate to the application.\n\n" + "Output:\n" + "- A Mermaid `graph TD` of the supervision tree (edge = supervises; label children with kind and restart " + "type). Tools take format=mermaid and return the diagram from the reported edges.\n" + "- A table: name/pid, module, kind, restart, msg queue len, membership (confirmed/inferred).\n" + "- \"Coverage gaps\": every truncated/omission/timeout reported by the tools.\n" + "- 3-5 observations (single points of failure, deep trees, hot queues, temporary children).\n\n" + "Rules: use only what the tools returned. Structural edges are not message traffic, so do not describe " + "runtime call flows. Keep detail=summary unless I ask for the full JSON."]). + +%%------------------------------------------------------------------------------ +%% Definitions +%%------------------------------------------------------------------------------ + +def(<<"runtime_summary">>) -> + {<<"Runtime summary">>, + <<"Bounded metadata of the debugged Erlang node: OTP/ERTS versions, node name (host redacted by default), " + "uptime, scheduler count, run queue length, process/atom/port/ETS counts against their VM limits, memory " + "summary and the opaque debug session id. " + "Start here, then call application_overview.">>, + obj(#{<<"redactNodeHost">> => bool(<<"Replace the host part of the node name by . Default true.">>)}, []), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"otpRelease">> => str(), <<"ertsVersion">> => str(), <<"node">> => str(), + <<"uptimeMs">> => int(), <<"schedulers">> => int(), <<"processCount">> => int(), + <<"schedulersOnline">> => int(), <<"runQueue">> => int(), + <<"connectedNodes">> => #{<<"type">> => <<"object">>}, + <<"resources">> => #{<<"type">> => <<"object">>}, + <<"memory">> => #{<<"type">> => <<"object">>}}, + [<<"schemaVersion">>, <<"sessionId">>])}; + +def(<<"application_overview">>) -> + {<<"Application overview">>, + <<"Discover the started OTP applications of the node without knowing any process name: names, versions, " + "declared dependencies (declarations, not evidence of runtime communication) and discoverable root " + "supervisors with reusable entity ids. Returns a paginated map (entities, typed relationships, coverage " + "limitations). Never calls application callbacks, loads code or reads application environment.">>, + obj(#{<<"application">> => id_prop(<<"Only this application (entity id from a previous call).">>), + <<"includeModules">> => bool(<<"Also list module entities (with declared behaviours when the module is already loaded). Default false.">>), + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"supervision_tree">>) -> + {<<"Supervision tree">>, + <<"Traverse supervisors from an application id or process (supervisor) id discovered by application_overview, " + "or from an explicitly given local supervisor (registered name or pid). Uses supervisor APIs with deadlines: " + "an unresponsive supervisor yields a partial result with a 'timeout' omission. Emits typed 'supervises' edges, " + "child kind, module and restart/shutdown metadata; never child start arguments. Never resumes or alters processes.">>, + obj(#{<<"id">> => id_prop(<<"Application or process entity id.">>), + <<"supervisor">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Registered name or local pid text of a supervisor.">>}, + <<"maxDepth">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 16}, + <<"includeModules">> => bool(<<"Add module entities and uses_module edges. Default false.">>), + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"registered_processes">>) -> + {<<"Registered processes">>, + <<"Bounded list of locally registered process names with safe summary metadata. Membership of an application is " + "reported as confirmed (found in its supervision tree), inferred (group leader) or unknown; unrelated names are " + "never silently assigned to the selected application. Does not enumerate unregistered processes.">>, + obj(#{<<"application">> => id_prop(<<"Only processes attributed to this application.">>), + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"process_info">>) -> + {<<"Process info">>, + <<"Allowlisted fields of one local process (by entity id, registered name or pid): registered name, status, " + "current function, initial call, reductions, memory (words), message queue length, links and monitors as " + "bounded identifiers, application membership with evidence. No dictionary, stack, mailbox or state.">>, + obj(#{<<"id">> => id_prop(<<"Process entity id.">>), + <<"name">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Registered name (must be an existing atom).">>}, + <<"pid">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 64, + <<"description">> => <<"Local pid text such as <0.123.0>.">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"ets_tables">>) -> + {<<"ETS tables">>, + <<"Metadata of the named ETS tables explicitly approved by the project policy (allowed_ets_tables): name, " + "protection, type, owner entity id, size and memory with units. Private tables are excluded. Never reads keys, " + "objects or values.">>, + obj(#{<<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"topology_overview">>) -> + {<<"Topology overview">>, + <<"One-call map of a single started application: the application with its declared dependencies and root, " + "its supervision tree (with child counts), its registered processes and the approved ETS tables owned by those " + "processes, as one collection of entities and typed relationships. Give the application by entity id or by " + "name. Parts whose own tool is not allowed by the project policy are left out and reported as a policy_denied " + "omission. For several applications, deeper subtrees or exports use the individual tools.">>, + obj(#{<<"application">> => id_prop(<<"Application entity id from a previous call.">>), + <<"name">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Name of a started application (must be an existing atom).">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"changes_since">>) -> + {<<"Changes since a collection">>, + <<"Observe again the tool and arguments of an earlier collection (collectionId of a previous graph result) and " + "return only what changed: added, removed and replaced entities (replaced = the same logical child with a new " + "id, i.e. restarted or recreated; previousId names the old one), plus the edges leading to new entities. " + "A removal from a partial collection is marked absence=unconfirmed. The baseline is only retained for a short " + "time (collection_ttl_ms, few collections): compare soon after the first call, otherwise baseline_expired.">>, + obj(#{<<"collectionId">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 64, + <<"description">> => <<"collectionId of the baseline (from a previous graph result).">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, [<<"collectionId">>]), + graph_output()}; + +def(<<"process_state">>) -> + {<<"Process state (developer tier)">>, + <<"The state of one gen_server, gen_statem or gen_event process (sys:get_state with a short timeout), " + "by entity id, registered name or pid. Bounded (depth, items, binary size); values of keys named like a secret " + "(password, token, ...) and credentials in URLs are replaced, but a record without keys cannot be redacted: " + "the result can contain application data. Only enabled when the project lists it in allowed_tools. " + "Fails for a process stopped at a breakpoint (reported as such).">>, + obj(#{<<"id">> => id_prop(<<"Process entity id.">>), + <<"name">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Registered name (must be an existing atom).">>}, + <<"pid">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 64, + <<"description">> => <<"Local pid text such as <0.123.0>.">>}}, []), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"process">> => str(), <<"state">> => #{}}, [<<"schemaVersion">>, <<"sessionId">>])}; + +def(<<"mailbox_sample">>) -> + {<<"Mailbox sample (developer tier)">>, + <<"The queue length and the oldest few messages (default 5, max 20) of one local process, by entity id, registered " + "name or pid. Messages are bounded and encoded like process_state (best-effort redaction); a mailbox of more than " + "20000 messages is not copied, only its length is reported. Only enabled when the project lists it in " + "allowed_tools.">>, + obj(#{<<"id">> => id_prop(<<"Process entity id.">>), + <<"name">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Registered name (must be an existing atom).">>}, + <<"pid">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 64, + <<"description">> => <<"Local pid text such as <0.123.0>.">>}, + <<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 20}}, []), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"process">> => str(), <<"queueLength">> => int(), <<"sample">> => #{<<"type">> => <<"array">>}}, + [<<"schemaVersion">>, <<"sessionId">>])}; + +def(<<"ets_sample">>) -> + {<<"ETS sample (developer tier)">>, + <<"A few objects (default 5, max 20) of one named ETS table approved by the project (allowed_ets_tables: exact " + "names, or all). Never a private table. Objects are bounded and encoded like process_state (best-effort " + "redaction). Only enabled when the project lists it in allowed_tools.">>, + obj(#{<<"table">> => #{<<"type">> => <<"string">>, <<"maxLength">> => 255, + <<"description">> => <<"Name of the table (must be an existing atom).">>}, + <<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 20}}, [<<"table">>]), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"table">> => str(), <<"size">> => int(), <<"sample">> => #{<<"type">> => <<"array">>}}, + [<<"schemaVersion">>, <<"sessionId">>])}; + +def(<<"process_groups">>) -> + {<<"Process groups">>, + <<"Aggregate every local process by where it was started (initial call; for gen_server and friends the callback " + "module's init) and return the largest groups (default 10, max 50) by process count, memory, reductions or " + "queue length: totals, shares of the node, registered versus unregistered, inferred application membership and " + "up to 3 sample process ids per group (usable with process_info). Finds fan-out and leaks - thousands of " + "anonymous workers - that a top list of single processes does not show. The answer has a fixed size however " + "many processes exist; the scan is bounded (process cap, time budget, group cap) and says when it was cut. " + "Metadata only.">>, + obj(#{<<"sortBy">> => #{<<"type">> => <<"string">>, + <<"enum">> => [<<"count">>, <<"memory">>, <<"reductions">>, <<"message_queue_len">>], + <<"description">> => <<"Ranking criterion. Default count.">>}, + <<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 50, + <<"description">> => <<"Number of groups to return. Default 10.">>}}, []), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"totals">> => #{<<"type">> => <<"object">>}, <<"groups">> => #{<<"type">> => <<"array">>}, + <<"complete">> => #{<<"type">> => <<"boolean">>}, <<"omissions">> => #{<<"type">> => <<"array">>}}, + [<<"schemaVersion">>, <<"sessionId">>])}; + +def(<<"top_ports">>) -> + {<<"Top ports">>, + <<"Rank the local ports of the node (sockets, files, spawned programs) by queue size, bytes in or bytes out and " + "return the top ones (default 10, max 50) with their driver name, byte counters, queue size and the owner " + "process (owns_port edge). Addresses, command lines, paths and data are never read; a port that is not a plain " + "driver name is reported as . Point-in-time scan.">>, + obj(#{<<"sortBy">> => #{<<"type">> => <<"string">>, + <<"enum">> => [<<"queue_size">>, <<"input">>, <<"output">>], + <<"description">> => <<"Ranking criterion. Default queue_size.">>}, + <<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 50, + <<"description">> => <<"Number of ports to return. Default 10.">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"ets_summary">>) -> + {<<"ETS summary">>, + <<"ETS memory aggregated per owner process, largest first (default 10 owners, max 50): number of tables and " + "memory in words and bytes, with application membership evidence, plus node totals in the scope. No table " + "name, key, object or value is read and no table inventory is listed; use ets_tables for the metadata of a " + "specific approved table.">>, + obj(#{<<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 50, + <<"description">> => <<"Number of owners to return. Default 10.">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"top_processes">>) -> + {<<"Top processes">>, + <<"Rank the local processes of the node by message queue length, reductions or memory and return the top ones " + "(default 10, max 50) with status, current function, initial call, registered name and application membership " + "evidence. Use it to find hot, stuck or heavy processes without calling process_info on each one. A bounded " + "point-in-time scan: processes start and exit during it, reductions are cumulative since process start. " + "Never reads mailbox, dictionary, stack or state.">>, + obj(#{<<"sortBy">> => #{<<"type">> => <<"string">>, + <<"enum">> => [<<"message_queue_len">>, <<"reductions">>, <<"memory">>], + <<"description">> => <<"Ranking criterion. Default message_queue_len.">>}, + <<"limit">> => #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 50, + <<"description">> => <<"Number of processes to return. Default 10.">>}, + <<"cursor">> => cursor_prop(), + <<"detail">> => detail_prop(), <<"pageSize">> => page_size_prop(), + <<"format">> => format_prop()}, []), + graph_output()}; + +def(<<"debug_session">>) -> + {<<"Debug session">>, + <<"Safe metadata of the current debug session: launch or attach, node connection, interpreted modules and " + "active breakpoint locations (module and line), and the processes currently stopped at a breakpoint (process id, " + "module and line). No source, environment or credentials.">>, + obj(#{}, []), + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"observedAt">> => str(), + <<"mode">> => str(), <<"nodeConnected">> => #{<<"type">> => <<"boolean">>}, + <<"interpretedModules">> => #{<<"type">> => <<"array">>}, + <<"breakpoints">> => #{<<"type">> => <<"array">>}, + <<"pausedProcesses">> => #{<<"type">> => <<"array">>}}, + [<<"schemaVersion">>, <<"sessionId">>])}. + +structured_error_schema() -> + obj(#{<<"error">> => obj(#{<<"code">> => str(), <<"message">> => str()}, [<<"code">>])}, [<<"error">>]). + +graph_output() -> + Arr = #{<<"type">> => <<"array">>}, + obj(#{<<"schemaVersion">> => str(), <<"sessionId">> => str(), <<"collectionId">> => str(), + <<"startedAt">> => str(), <<"finishedAt">> => str(), + <<"scope">> => #{<<"type">> => <<"object">>}, + <<"entities">> => Arr, <<"relationships">> => Arr, + <<"complete">> => #{<<"type">> => <<"boolean">>}, + <<"truncated">> => #{<<"type">> => <<"boolean">>}, + <<"omissions">> => Arr, + <<"nextCursor">> => #{<<"type">> => [<<"string">>, <<"null">>]}}, + [<<"schemaVersion">>, <<"sessionId">>, <<"collectionId">>, <<"startedAt">>, <<"finishedAt">>, + <<"scope">>, <<"entities">>, <<"relationships">>, <<"complete">>, <<"truncated">>, + <<"omissions">>, <<"nextCursor">>]). + +obj(Props, Required) -> + #{<<"type">> => <<"object">>, <<"properties">> => Props, + <<"required">> => Required, <<"additionalProperties">> => false}. + +str() -> #{<<"type">> => <<"string">>}. +int() -> #{<<"type">> => <<"integer">>}. +bool(Desc) -> #{<<"type">> => <<"boolean">>, <<"description">> => Desc}. +id_prop(Desc) -> #{<<"type">> => <<"string">>, <<"maxLength">> => 64, <<"description">> => Desc}. +format_prop() -> + #{<<"type">> => <<"string">>, <<"enum">> => [<<"json">>, <<"mermaid">>], + <<"description">> => <<"json (default): entities and relationships only. mermaid: also return a 'mermaid' field " + "with a ready-to-render `graph TD` of this page (built from the returned edges, so it " + "cannot show a relationship the tools did not report).">>}. + +detail_prop() -> + #{<<"type">> => <<"string">>, <<"enum">> => [<<"summary">>, <<"full">>], + <<"description">> => <<"summary (default): compact entities/edges, 100 items per page - best for reasoning. " + "full: every field (pid, descriptions, edge observedAt...) and the largest pages - " + "use it only to export the complete JSON (e.g. to write it to a file).">>}. + +page_size_prop() -> + #{<<"type">> => <<"integer">>, <<"minimum">> => 1, <<"maximum">> => 500, + <<"description">> => <<"Items (entities + relationships) per page; capped by the project policy.">>}. + +cursor_prop() -> + #{<<"type">> => <<"string">>, <<"maxLength">> => 200, + <<"description">> => <<"nextCursor of a previous call with the same arguments.">>}. + +%%------------------------------------------------------------------------------ +%% Validation of arguments against the input schema (strict subset of JSON schema) +%%------------------------------------------------------------------------------ + +%% -> ok | {error, Message} +validate_args(Tool, Args) -> + case known(Tool) of + false -> {error, <<"unknown tool">>}; + true -> + {_, _, Schema, _} = def(Tool), + case is_map(Args) of + false -> {error, <<"arguments must be an object">>}; + true -> + case validate_object(Schema, Args) of + ok -> exclusive(Tool, Args); + {error, _} = E -> E + end + end + end. + +exclusive(Tool, Args) when Tool =:= <<"process_info">>; Tool =:= <<"process_state">>; Tool =:= <<"mailbox_sample">> -> + case length([K || K <- [<<"id">>, <<"name">>, <<"pid">>], maps:is_key(K, Args)]) of + 1 -> ok; + _ -> {error, <<"exactly one of id, name or pid is required">>} + end; +exclusive(<<"topology_overview">>, Args) -> + case maps:is_key(<<"cursor">>, Args) of + true -> ok; + false -> + case length([K || K <- [<<"application">>, <<"name">>], maps:is_key(K, Args)]) of + 1 -> ok; + _ -> {error, <<"exactly one of application or name is required">>} + end + end; +exclusive(<<"supervision_tree">>, Args) -> + case maps:is_key(<<"cursor">>, Args) of + true -> ok; + false -> + case length([K || K <- [<<"id">>, <<"supervisor">>], maps:is_key(K, Args)]) of + 1 -> ok; + _ -> {error, <<"exactly one of id or supervisor is required">>} + end + end; +exclusive(_, _) -> + ok. + +validate_object(#{<<"properties">> := Props, <<"required">> := Required}, Args) -> + Unknown = [K || K <- maps:keys(Args), not maps:is_key(K, Props)], + Missing = [K || K <- Required, not maps:is_key(K, Args)], + case {Unknown, Missing} of + {[_ | _], _} -> {error, <<"unexpected argument">>}; + {_, [_ | _]} -> {error, <<"missing required argument">>}; + _ -> validate_props(maps:to_list(Args), Props) + end. + +validate_props([], _) -> ok; +validate_props([{K, V} | T], Props) -> + case validate_value(maps:get(K, Props), V) of + ok -> validate_props(T, Props); + {error, Msg} -> {error, <<"invalid argument '", K/binary, "': ", Msg/binary>>} + end. + +validate_value(#{<<"type">> := <<"string">>, <<"enum">> := Enum}, V) when is_binary(V) -> + case lists:member(V, Enum) of + true -> ok; + false -> {error, <<"not an allowed value">>} + end; +validate_value(#{<<"type">> := <<"string">>} = S, V) when is_binary(V) -> + case unicode:characters_to_binary(V, utf8, utf8) of + B when is_binary(B) -> + case byte_size(V) > maps:get(<<"maxLength">>, S, 4096) of + true -> {error, <<"too long">>}; + false -> ok + end; + _ -> {error, <<"not valid UTF-8">>} + end; +validate_value(#{<<"type">> := <<"boolean">>}, V) when is_boolean(V) -> ok; +validate_value(#{<<"type">> := <<"integer">>} = S, V) when is_integer(V) -> + Min = maps:get(<<"minimum">>, S, V), + Max = maps:get(<<"maximum">>, S, V), + case V >= Min andalso V =< Max of + true -> ok; + false -> {error, <<"out of range">>} + end; +validate_value(#{<<"type">> := T}, _) -> + {error, <<"expected ", T/binary>>}. diff --git a/apps/erlangbridge/src/vscode_connection.erl b/apps/erlangbridge/src/vscode_connection.erl index 476e7dad..c506bf84 100644 --- a/apps/erlangbridge/src/vscode_connection.erl +++ b/apps/erlangbridge/src/vscode_connection.erl @@ -77,6 +77,10 @@ configure(no_compiled_args_file) -> %%------------------------------------------------------------------------------ -define(ATTACH_MODULES, [vscode_jsone, gen_connection, vscode_connection]). +%% Pushed to the target only when the embedded MCP inspector is enabled for +%% this debug session (the helper is then started with -vscode_mcp). +-define(MCP_MODULES, [vscode_jsone_decode, mcp_policy, mcp_encoder, mcp_audit, mcp_store, + mcp_tools, mcp_runtime, mcp_server, mcp_sup]). attach() -> {ok, [[NodeString]]} = init:get_argument(vscode_attach_node), @@ -97,7 +101,7 @@ attach(Node, Port, ArgsModule) -> true -> case missing_apps(Node) of [] -> - lists:foreach(fun(M) -> push_module(Node, M) end, ?ATTACH_MODULES), + lists:foreach(fun(M) -> push_module(Node, M) end, ?ATTACH_MODULES ++ mcp_modules(Node)), case rpc:call(Node, ?MODULE, start_attached, [Port, ArgsModule], infinity) of ok -> ok; Other -> {error, io_lib:format("~p", [Other])} @@ -111,9 +115,26 @@ attach(Node, Port, ArgsModule) -> "is running on this machine, and the cookie"} end. -%% int (debugger app) runs the interpreter, httpc (inets app) posts the events. +%% int (debugger app) runs the interpreter, httpc (inets app) posts the events; +%% the MCP inspector also needs crypto (token, cursor signatures). missing_apps(Node) -> - [M || M <- [int, httpc], rpc:call(Node, code, which, [M]) =:= non_existing]. + Needed = case mcp_requested() of + true -> [int, httpc, crypto]; + false -> [int, httpc] + end, + [M || M <- Needed, rpc:call(Node, code, which, [M]) =:= non_existing]. + +mcp_requested() -> + case init:get_argument(vscode_mcp) of + {ok, _} -> true; + _ -> false + end. + +mcp_modules(_Node) -> + case mcp_requested() of + true -> ?MCP_MODULES; + false -> [] + end. push_module(Node, Module) -> {Module, Binary, File} = code:get_object_code(Module), @@ -160,6 +181,7 @@ detach() -> [int:continue(Pid) || {Pid, _, break, _} <- int:snapshot()], [int:nn(M) || M <- int:interpreted()], int:clear(), + stop_mcp(), stop_attached(), application:unset_env(vscode_debugger, port), ok. @@ -255,6 +277,13 @@ decode_request(Data) -> {error,{_,erl_parse, Messages}} -> debugger_eval_error(lists:flatten(Messages)) end; + {mcp_start, Body} -> + mcp_start(Body); + {mcp_renew, _Body} -> + mcp_renew(); + {mcp_stop, _Body} -> + stop_mcp(), + #{ok => true}; {debugger_exit, _Body} -> init:stop(0); {debugger_detach, _Body} -> @@ -265,6 +294,61 @@ decode_request(Data) -> unknown_command end. +%%------------------------------------------------------------------------------ +%% Embedded MCP inspector (debug sessions only, enabled by the adapter) +%% +%% The adapter posts the normalized, non-secret configuration; the token is +%% generated here and returned only to the adapter that started the session. +%% It never appears in a debug event, an output event or a log. +%%------------------------------------------------------------------------------ + +mcp_start(Body) -> + case code:which(mcp_sup) of + non_existing -> mcp_error(<<"the MCP inspector is not available in the debugged node">>); + _ -> + try vscode_jsone_decode:decode(unicode:characters_to_binary(Body)) of + {ok, Map, _} when is_map(Map) -> + case mcp_policy:from_json_map(Map) of + {ok, Config} -> + Fixed = maps:get(<<"authToken">>, Map, undefined), + Notify = fun(Meta) -> gen_connection:send_message_to_vscode(port_int(), "mcp_call", Meta) end, + case mcp_sup:start_session(Config, mcp_mode(Map), Fixed, #{notify => Notify}) of + {ok, #{host := Host, port := Port, path := Path, sessionId := Sid, token := Token}} -> + #{ok => true, host => list_to_binary(Host), port => Port, + path => Path, sessionId => Sid, token => Token}; + {error, Msg} -> mcp_error(Msg) + end; + {error, Msg} -> mcp_error(unicode:characters_to_binary(Msg)) + end; + _ -> mcp_error(<<"invalid MCP configuration">>) + catch _:_ -> mcp_error(<<"invalid MCP configuration">>) + end + end. + +%% get_port/0 returns the -vscode_port argument (a string) or, in attach mode, the stored port. +port_int() -> + case get_port() of + P when is_integer(P) -> P; + P when is_list(P) -> list_to_integer(P) + end. + +mcp_mode(#{<<"mode">> := <<"attach">>}) -> attach; +mcp_mode(_) -> launch. + +mcp_error(Msg) -> #{ok => false, error => Msg}. + +mcp_renew() -> + case code:which(mcp_sup) of + non_existing -> #{ok => false}; + _ -> case mcp_sup:renew() of ok -> #{ok => true}; _ -> #{ok => false} end + end. + +stop_mcp() -> + case code:is_loaded(mcp_sup) of + false -> ok; + _ -> catch mcp_sup:stop_session(), ok + end. + debugger_eval_error(Message) -> #{value => iolist_to_binary(Message), type => <<"error">>}. diff --git a/apps/erlangbridge/test/mcp/mcp_bridge_SUITE.erl b/apps/erlangbridge/test/mcp/mcp_bridge_SUITE.erl new file mode 100644 index 00000000..0cd30640 --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_bridge_SUITE.erl @@ -0,0 +1,282 @@ +-module(mcp_bridge_SUITE). + +%% Debugger-bridge integration of the MCP inspector (T12): the inspector is +%% pushed to and started inside the *attached target node* only when the +%% adapter asks for it, reports the target's own state, is stopped by a detach, +%% and never touches the node running the helper (LSP/adapter side). +%% +%% Same shape as vscode_connection_SUITE: the test node plays the debug adapter +%% (event receiver + commands), a peer is the target, a second peer (started +%% with -vscode_mcp) or the test node itself is the attach helper. + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-compile([export_all, nowarn_export_all]). + +all() -> + [inspector_is_pushed_started_and_stopped_with_the_attach, + without_the_mcp_flag_nothing_is_pushed_or_started, + start_is_refused_for_an_invalid_configuration, + fixed_development_token_is_used_when_given]. + +init_per_suite(Config) -> + case ensure_distributed() of + ok -> Config; + {error, Reason} -> {skip, {no_distribution, Reason}} + end. + +end_per_suite(_Config) -> ok. + +init_per_testcase(_Case, Config) -> + {ok, Peer, Target} = peer:start_link(#{name => peer:random_name(?MODULE), connection => standard_io}), + Src = copy_fixture(Config), + {ok, mcp_attach_target} = compile:file(Src, [debug_info, {outdir, filename:dirname(Src)}]), + true = peer:call(Peer, code, add_patha, [filename:dirname(Src)]), + {Receiver, Port} = start_event_receiver(), + [{peer, Peer}, {target, Target}, {src, Src}, {receiver, Receiver}, {port, Port} | Config]. + +end_per_testcase(_Case, Config) -> + exit(?config(receiver, Config), kill), + catch peer:stop(?config(helper, Config)), + peer:stop(?config(peer, Config)). + +%% Helper started like the adapter does for an MCP-enabled attach: -vscode_mcp 1. +start_helper(Config) -> + Paths = lists:append([["-pa", P] || P <- code:get_path()]), + {ok, Helper, _} = peer:start_link(#{name => peer:random_name("mcp_helper"), connection => standard_io, + args => ["-vscode_mcp", "1"] ++ Paths}), + [{helper, Helper} | Config]. + +%%%%%%%%%%% +%% cases %% +%%%%%%%%%%% + +inspector_is_pushed_started_and_stopped_with_the_attach(Config0) -> + Config = start_helper(Config0), + Target = ?config(target, Config), + Helper = ?config(helper, Config), + ?assertEqual(ok, peer:call(Helper, vscode_connection, attach, + [Target, port_arg(Config), args_module(Config)], 60000)), + CmdPort = wait_listen(), + %% pushed to the target, because -vscode_mcp was requested + ?assertNotEqual(non_existing, rpc:call(Target, code, which, [mcp_sup])), + ?assertNotEqual(non_existing, rpc:call(Target, code, which, [mcp_server])), + %% nothing runs until the adapter asks + ?assertEqual(false, rpc:call(Target, mcp_sup, active, [])), + Start = decode(post_command(CmdPort, "mcp_start", start_body(#{}))), + ?assertMatch(#{<<"ok">> := true, <<"host">> := <<"127.0.0.1">>, <<"path">> := <<"/mcp">>, + <<"port">> := P, <<"token">> := T, <<"sessionId">> := _} when is_integer(P) andalso P > 0 andalso is_binary(T), + Start), + #{<<"port">> := McpPort, <<"token">> := Token} = Start, + ?assert(byte_size(Token) >= 43), + %% the inspector lives in the target, not in the adapter/helper/test nodes + ?assertEqual(true, rpc:call(Target, mcp_sup, active, [])), + ?assertEqual(false, mcp_sup:active()), + ?assertEqual(undefined, whereis(mcp_server)), + ?assertEqual(false, peer:call(Helper, mcp_sup, active, [])), + %% it reports the target's own state (attach mode, interpreted module, breakpoint) + Summary = tool(McpPort, Token, <<"runtime_summary">>, #{<<"redactNodeHost">> => false}), + ?assertEqual(atom_to_binary(Target, utf8), maps:get(<<"node">>, Summary)), + Debug = tool(McpPort, Token, <<"debug_session">>, #{}), + ?assertEqual(<<"attach">>, maps:get(<<"mode">>, Debug)), + ?assertEqual([<<"mcp_attach_target">>], maps:get(<<"interpretedModules">>, Debug)), + ?assertEqual([#{<<"module">> => <<"mcp_attach_target">>, <<"line">> => 5}], maps:get(<<"breakpoints">>, Debug)), + %% no source, environment or credentials + Text = iolist_to_binary(io_lib:format("~p", [Debug])), + ?assertEqual(nomatch, binary:match(Text, list_to_binary(?config(src, Config)))), + ?assertEqual(nomatch, binary:match(Text, Token)), + %% the token was not sent through the debug event channel + ?assertEqual(nomatch, binary:match(iolist_to_binary(io_lib:format("~p", [events()])), Token)), + %% the request journal reaches the adapter as /mcp_call events, without the token + Journal = wait_event(<<"/mcp_call">>), + %% (journal entries are sent asynchronously: any of the two calls may come first) + ?assertMatch(#{<<"method">> := <<"tools/call">>, <<"status">> := <<"ok">>, <<"seq">> := S} when S >= 1, + decode(Journal)), + ?assertEqual(nomatch, binary:match(Journal, Token)), + %% lease renewal from the adapter + ?assertMatch(#{<<"ok">> := true}, decode(post_command(CmdPort, "mcp_renew", <<>>))), + %% a second start on the same node is refused + ?assertMatch(#{<<"ok">> := false, <<"error">> := _}, decode(post_command(CmdPort, "mcp_start", start_body(#{})))), + %% detach: the inspector is stopped, its endpoint closed, the target keeps running + ?assertEqual(<<"{}">>, post_command(CmdPort, "debugger_detach", <<>>)), + wait_until(fun() -> rpc:call(Target, mcp_sup, active, []) =:= false end), + ?assertMatch({error, econnrefused}, gen_tcp:connect({127, 0, 0, 1}, McpPort, [binary], 1000)), + ?assertEqual(3, rpc:call(Target, mcp_attach_target, add, [1, 2])), + ?assert(is_pid(rpc:call(Target, erlang, whereis, [init]))). + +without_the_mcp_flag_nothing_is_pushed_or_started(Config) -> + Target = ?config(target, Config), + ok = vscode_connection:attach(Target, port_arg(Config), args_module(Config)), + CmdPort = wait_listen(), + ?assertEqual(non_existing, rpc:call(Target, code, which, [mcp_sup])), + ?assertEqual(non_existing, rpc:call(Target, code, which, [mcp_policy])), + ?assertMatch(#{<<"ok">> := false, <<"error">> := <<"the MCP inspector is not available", _/binary>>}, + decode(post_command(CmdPort, "mcp_start", start_body(#{})))), + ?assertMatch(#{<<"ok">> := false}, decode(post_command(CmdPort, "mcp_renew", <<>>))), + ?assertEqual(<<"{}">>, post_command(CmdPort, "debugger_detach", <<>>)), + wait_until(fun() -> rpc:call(Target, erlang, whereis, [vscode_connection]) =:= undefined end). + +start_is_refused_for_an_invalid_configuration(Config0) -> + Config = start_helper(Config0), + Target = ?config(target, Config), + ok = peer:call(Helper = ?config(helper, Config), vscode_connection, attach, + [Target, port_arg(Config), args_module(Config)], 60000), + _ = Helper, + CmdPort = wait_listen(), + [begin + ?assertMatch(#{<<"ok">> := false, <<"error">> := _}, decode(post_command(CmdPort, "mcp_start", Body))), + ?assertEqual(false, rpc:call(Target, mcp_sup, active, [])) + end || Body <- [start_body(#{<<"host">> => <<"0.0.0.0">>}), + start_body(#{<<"port">> => 70000}), + start_body(#{<<"authToken">> => <<"x">>}), + start_body(#{<<"required">> => true}), + <<"not json">>, + <<"[]">>]], + ?assertEqual(<<"{}">>, post_command(CmdPort, "debugger_detach", <<>>)). + +fixed_development_token_is_used_when_given(Config0) -> + Config = start_helper(Config0), + Target = ?config(target, Config), + ok = peer:call(?config(helper, Config), vscode_connection, attach, + [Target, port_arg(Config), args_module(Config)], 60000), + CmdPort = wait_listen(), + Fixed = <<"dev-fixed-token-0123456789">>, + #{<<"ok">> := true, <<"port">> := Port, <<"token">> := Fixed} = + decode(post_command(CmdPort, "mcp_start", start_body(#{<<"authToken">> => Fixed}))), + ?assertMatch(#{<<"sessionId">> := _}, tool(Port, Fixed, <<"runtime_summary">>, #{})), + %% the fixed token is the only credential + {ok, S} = gen_tcp:connect({127, 0, 0, 1}, Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(S, ["POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:", integer_to_list(Port), + "\r\nAuthorization: Bearer wrong\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}"]), + ?assertMatch(<<"HTTP/1.1 401", _/binary>>, recv_all(S, <<>>)), + ?assertEqual(<<"{}">>, post_command(CmdPort, "debugger_detach", <<>>)). + +%%%%%%%%%%%%% +%% helpers %% +%%%%%%%%%%%%% + +start_body(Override) -> + Base = (mcp_policy:to_json_map(mcp_policy:defaults()))#{<<"mode">> => <<"attach">>}, + {ok, Json} = vscode_jsone:encode(maps:merge(Base, Override)), + iolist_to_binary(Json). + +decode(Bin) -> + {ok, T, _} = vscode_jsone_decode:decode(Bin), + T. + +%% MCP tool call over the real endpoint -> structuredContent +tool(Port, Token, Name, Args) -> + Req = #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => 1, <<"method">> => <<"tools/call">>, + <<"params">> => #{<<"name">> => Name, <<"arguments">> => Args}}, + {ok, Json} = vscode_jsone:encode(Req), + Body = iolist_to_binary(Json), + {ok, S} = gen_tcp:connect({127, 0, 0, 1}, Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(S, ["POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:", integer_to_list(Port), + "\r\nAuthorization: Bearer ", Token, "\r\nContent-Type: application/json\r\n" + "Content-Length: ", integer_to_list(byte_size(Body)), "\r\n\r\n", Body]), + Raw = recv_all(S, <<>>), + [_, RespBody] = binary:split(Raw, <<"\r\n\r\n">>), + #{<<"result">> := #{<<"isError">> := false, <<"structuredContent">> := Structured}} = decode(RespBody), + Structured. + +events() -> + receive {event, P, B} -> [{P, B} | events()] after 0 -> [] end. + +ensure_distributed() -> + case node() of + nonode@nohost -> + _ = os:cmd("epmd -daemon"), + case net_kernel:start([list_to_atom("mcp_bridge_suite_" ++ os:getpid()), shortnames]) of + {ok, _} -> ok; + {error, Reason} -> {error, Reason} + end; + _ -> ok + end. + +copy_fixture(Config) -> + Dir = filename:join(?config(priv_dir, Config), "attach"), + ok = filelib:ensure_dir(filename:join(Dir, "x")), + Src = filename:join(Dir, "mcp_attach_target.erl"), + {ok, _} = file:copy(filename:join(?config(data_dir, Config), "mcp_attach_target.erl"), Src), + Src. + +port_arg(Config) -> integer_to_list(?config(port, Config)). + +args_module(Config) -> + Src = ?config(src, Config), + Forms = io_lib:format( + "-module(bp_attach_test).~n-export([configure/0]).~n" + "configure() -> int:start(), int:ni(~p), int:break(mcp_attach_target, 5), ok.~n", [Src]), + File = filename:join(filename:dirname(Src), "bp_attach_test.erl"), + ok = file:write_file(File, Forms), + {ok, bp_attach_test, Bin} = compile:file(File, [binary]), + {bp_attach_test, Bin}. + +wait_listen() -> + Body = wait_event(<<"/listen">>), + {match, [Port]} = re:run(Body, "\"port\":([0-9]+)", [{capture, all_but_first, list}]), + list_to_integer(Port). + +wait_event(Path) -> + receive + {event, Path, Body} -> Body + after 10000 -> ct:fail({no_event, Path}) + end. + +wait_until(Fun) -> wait_until(Fun, 50). +wait_until(_Fun, 0) -> ct:fail(condition_never_met); +wait_until(Fun, N) -> + case Fun() of + true -> ok; + false -> timer:sleep(100), wait_until(Fun, N - 1) + end. + +start_event_receiver() -> + Self = self(), + {ok, LSock} = gen_tcp:listen(0, [binary, {packet, http_bin}, {active, false}, + {ip, {127,0,0,1}}, {reuseaddr, true}]), + {ok, Port} = inet:port(LSock), + Pid = spawn(fun() -> accept_loop(LSock, Self) end), + ok = gen_tcp:controlling_process(LSock, Pid), + {Pid, Port}. + +accept_loop(LSock, Test) -> + {ok, Sock} = gen_tcp:accept(LSock), + spawn(fun() -> serve(Sock, Test) end), + accept_loop(LSock, Test). + +serve(Sock, Test) -> + {ok, {http_request, 'POST', {abs_path, Path}, _}} = gen_tcp:recv(Sock, 0, 5000), + Len = content_length(Sock, 0), + ok = inet:setopts(Sock, [{packet, raw}]), + Body = case Len of + 0 -> <<>>; + _ -> {ok, B} = gen_tcp:recv(Sock, Len, 5000), B + end, + Test ! {event, Path, Body}, + gen_tcp:send(Sock, <<"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok">>), + gen_tcp:close(Sock). + +content_length(Sock, Len) -> + case gen_tcp:recv(Sock, 0, 5000) of + {ok, {http_header, _, 'Content-Length', _, V}} -> content_length(Sock, binary_to_integer(V)); + {ok, {http_header, _, _, _, _}} -> content_length(Sock, Len); + {ok, http_eoh} -> Len + end. + +%% Command as the debug adapter sends it (erlangConnection.ts post/3), with a body. +post_command(CmdPort, Verb, Body) -> + {ok, Sock} = gen_tcp:connect({127,0,0,1}, CmdPort, [binary, {active, false}], 5000), + ok = gen_tcp:send(Sock, ["POST ", Verb, " HTTP/1.1\r\nContent-Type: plain/text\r\n" + "Content-Length: ", integer_to_list(byte_size(Body)), + "\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", Body]), + Response = recv_all(Sock, <<>>), + [_Headers, RespBody] = binary:split(Response, <<"\r\n\r\n">>), + RespBody. + +recv_all(Sock, Acc) -> + case gen_tcp:recv(Sock, 0, 5000) of + {ok, Data} -> recv_all(Sock, <>); + {error, _} -> Acc + end. diff --git a/apps/erlangbridge/test/mcp/mcp_bridge_SUITE_data/mcp_attach_target.erl b/apps/erlangbridge/test/mcp/mcp_bridge_SUITE_data/mcp_attach_target.erl new file mode 100644 index 00000000..8bca4f3d --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_bridge_SUITE_data/mcp_attach_target.erl @@ -0,0 +1,5 @@ +-module(mcp_attach_target). +-export([add/2]). + +add(A, B) -> + A + B. diff --git a/apps/erlangbridge/test/mcp/mcp_capture_logger.erl b/apps/erlangbridge/test/mcp/mcp_capture_logger.erl new file mode 100644 index 00000000..b52b7beb --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_capture_logger.erl @@ -0,0 +1,8 @@ +%% Test-only logger handler: forwards every log event to the test process. +-module(mcp_capture_logger). + +-export([log/2]). + +log(Event, #{config := #{owner := Owner}}) -> + Owner ! {mcp_log, Event}, + ok. diff --git a/apps/erlangbridge/test/mcp/mcp_encoder_SUITE.erl b/apps/erlangbridge/test/mcp/mcp_encoder_SUITE.erl new file mode 100644 index 00000000..41621bde --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_encoder_SUITE.erl @@ -0,0 +1,134 @@ +-module(mcp_encoder_SUITE). + +%% Bounded, safe conversion of arbitrary terms (mcp_encoder). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-compile([export_all, nowarn_export_all]). + +all() -> + [scalars_and_special_terms, + binaries_are_validated_and_bounded, + depth_and_item_limits, + maps_with_non_string_keys_and_secret_keys, + improper_lists_and_unicode, + never_creates_atoms_and_always_json_encodable, + text_is_bounded, + credentials_are_redacted_in_terms_text_and_urls, + timestamps_and_hex]. + +credentials_are_redacted_in_terms_text_and_urls(_) -> + L = #{max_depth => 8, max_items => 20, max_binary_bytes => 256}, + %% key/value pairs (proplists, options) and map entries + ?assertEqual(#{<<"tuple">> => [<<"password">>, <<"">>]}, mcp_encoder:term({password, "hunter2"}, L)), + ?assertEqual(#{<<"tuple">> => [<<"db">>, <<"ok">>]}, mcp_encoder:term({db, ok}, L)), + %% printed form (child ids): same rule, nested, and URLs with credentials + T = mcp_encoder:text({conn, [{host, "h"}, {api_key, "SENTINEL_KEY"}, {opts, #{secret => "SENTINEL_S"}}]}, L), + ?assertEqual(nomatch, binary:match(T, <<"SENTINEL">>)), + ?assertNotEqual(nomatch, binary:match(T, <<"redacted">>)), + ?assertNotEqual(nomatch, binary:match(T, <<"host">>)), + ?assertEqual(<<"postgres://@db/app">>, mcp_encoder:scrub(<<"postgres://user:pw@db/app">>)), + ?assertEqual(<<"http://example.org/a:b">>, mcp_encoder:scrub(<<"http://example.org/a:b">>)), + ?assertEqual(<<"see now">>, + binary:replace(mcp_encoder:term(<<"see https://u:p@h now">>, L), <<"https://@h">>, <<"">>)), + ?assertEqual(<<"db: ">>, binary:replace(mcp_encoder:term("db: ftp://a:b@c", L), <<"ftp://@c">>, <<"">>)). + +limits() -> #{max_depth => 4, max_items => 5, max_binary_bytes => 16}. + +scalars_and_special_terms(_) -> + L = limits(), + ?assertEqual(42, mcp_encoder:term(42, L)), + ?assertEqual(1.5, mcp_encoder:term(1.5, L)), + ?assertEqual(true, mcp_encoder:term(true, L)), + ?assertEqual(null, mcp_encoder:term(undefined, L)), + ?assertEqual(<<"hello">>, mcp_encoder:term(hello, L)), + ?assertEqual([], mcp_encoder:term([], L)), + ?assertEqual(<<"<0.1.0>">>, mcp_encoder:term(list_to_pid("<0.1.0>"), L)), + ?assertMatch(#{<<"port">> := _}, mcp_encoder:term(hd(erlang:ports()), L)), + ?assertMatch(#{<<"ref">> := _}, mcp_encoder:term(make_ref(), L)), + %% functions are described, never a recoverable handle + Fun = mcp_encoder:term(fun lists:sort/1, L), + ?assertEqual(#{<<"fun">> => <<"lists">>}, Fun), + ?assertEqual(#{<<"fun">> => <<"mcp_encoder_SUITE">>}, mcp_encoder:term(fun() -> secret_closure end, L)), + ?assertEqual(nomatch, re:run(io_lib:format("~p", [Fun]), "sort")), + ?assertEqual(#{<<"tuple">> => [1, <<"a">>]}, mcp_encoder:term({1, a}, L)). + +binaries_are_validated_and_bounded(_) -> + L = limits(), + ?assertEqual(<<"short">>, mcp_encoder:term(<<"short">>, L)), + Long = binary:copy(<<"x">>, 100), + ?assertMatch(#{<<"binary">> := #{<<"size">> := 100, <<"truncated">> := true, <<"encoding">> := <<"base64">>}}, + mcp_encoder:term(Long, L)), + Bin = mcp_encoder:term(<<255, 254, 1, 2>>, L), + ?assertMatch(#{<<"binary">> := #{<<"size">> := 4, <<"truncated">> := false}}, Bin), + ?assertEqual(<<255, 254, 1, 2>>, base64:decode(maps:get(<<"data">>, maps:get(<<"binary">>, Bin)))), + %% base64 payload is bounded by max_binary_bytes, never the whole binary + Big = mcp_encoder:term(binary:copy(<<0>>, 1000000), L), + ?assert(byte_size(maps:get(<<"data">>, maps:get(<<"binary">>, Big))) =< 24), + %% safe_binary cuts on a character boundary + ?assertEqual(<<"aé"/utf8>>, mcp_encoder:safe_binary(<<"aéb"/utf8>>, 3)), + ?assertEqual(<<"a"/utf8>>, mcp_encoder:safe_binary(<<"aé"/utf8>>, 2)), + ?assertEqual(<<"">>, mcp_encoder:safe_binary(<<255>>, 10)). + +depth_and_item_limits(_) -> + L = limits(), + Deep = lists:foldl(fun(_, Acc) -> [Acc] end, x, lists:seq(1, 1000)), + Enc = mcp_encoder:term(Deep, L), + ?assertNotEqual(nomatch, re:run(io_lib:format("~p", [Enc]), "max depth")), + ?assert(length(lists:flatten(io_lib:format("~p", [Enc]))) < 400), + Many = mcp_encoder:term(lists:seq(1, 1000), L), + ?assertEqual([1, 2, 3, 4, 5, #{<<"truncated">> => true}], Many), + Tuple = mcp_encoder:term(list_to_tuple(lists:seq(1, 1000)), L), + ?assertEqual(6, length(maps:get(<<"tuple">>, Tuple))). + +maps_with_non_string_keys_and_secret_keys(_) -> + L = limits(), + M = mcp_encoder:term(#{1 => a, {t, 1} => b, <<"k">> => c, "str" => d}, L), + ?assertEqual(4, maps:get(<<"size">>, M)), + Keys = [maps:get(<<"key">>, E) || E <- maps:get(<<"map">>, M)], + ?assert(lists:member(1, Keys)), + ?assert(lists:member(#{<<"tuple">> => [<<"t">>, 1]}, Keys)), + %% likely secrets are redacted by key name + S = mcp_encoder:term(#{password => "SENTINEL_PW", <<"api_key">> => <<"SENTINEL_KEY">>, + auth_token => x, cookie => y, name => visible}, L), + Text = iolist_to_binary(io_lib:format("~p", [S])), + ?assertEqual(nomatch, binary:match(Text, <<"SENTINEL">>)), + ?assertNotEqual(nomatch, binary:match(Text, <<"visible">>)), + ?assertNotEqual(nomatch, binary:match(Text, <<"">>)), + Many = mcp_encoder:term(maps:from_list([{I, I} || I <- lists:seq(1, 100)]), L), + ?assertEqual(5, length(maps:get(<<"map">>, Many))), + ?assertEqual(100, maps:get(<<"size">>, Many)). + +improper_lists_and_unicode(_) -> + L = limits(), + ?assertEqual([1, 2, #{<<"improperTail">> => <<"tail">>}], mcp_encoder:term([1, 2 | tail], L)), + ?assertEqual(<<"héllo"/utf8>>, mcp_encoder:term("héllo", L#{max_binary_bytes => 64})), + ?assertEqual(<<"日本語"/utf8>>, mcp_encoder:term([26085, 26412, 35486], L#{max_binary_bytes => 64})), + %% invalid code points fall back to a bounded list instead of crashing + ?assert(is_list(mcp_encoder:term([1114112, 2], L)) orelse is_binary(mcp_encoder:term([1114112, 2], L))). + +never_creates_atoms_and_always_json_encodable(_) -> + L = limits(), + Terms = [self(), make_ref(), fun() -> ok end, {a, {b, {c, {d, {e, f}}}}}, #{a => #{b => #{c => #{d => #{e => f}}}}}, + [a | b], <<0, 255>>, "hi", 'weird atom', 1 bsl 200, -0.0, "", <<>>, {}, #{}], + Before = erlang:system_info(atom_count), + [begin + J = mcp_encoder:term(T, L), + {ok, Json} = vscode_jsone:encode(J), + ?assert(is_binary(iolist_to_binary(Json))) + end || T <- Terms], + [mcp_encoder:term(list_to_binary("unique_" ++ integer_to_list(I)), L) || I <- lists:seq(1, 500)], + ?assertEqual(Before, erlang:system_info(atom_count)). + +text_is_bounded(_) -> + L = limits(), + T = mcp_encoder:text(lists:seq(1, 100000), L), + ?assert(byte_size(T) =< 16), + ?assertEqual(<<"worker_a">>, mcp_encoder:text(worker_a, L#{max_binary_bytes => 64})), + ?assert(is_binary(mcp_encoder:text(make_ref(), L#{max_binary_bytes => 64}))). + +timestamps_and_hex(_) -> + ?assertEqual(<<"1970-01-01T00:00:00.000Z">>, mcp_encoder:iso8601(0)), + ?assertEqual(<<"2026-09-29T12:34:56.789Z">>, mcp_encoder:iso8601(1790685296789)), + ?assertEqual(<<"00ff10">>, mcp_encoder:hex(<<0, 255, 16>>)). diff --git a/apps/erlangbridge/test/mcp/mcp_fixture_paused.erl b/apps/erlangbridge/test/mcp/mcp_fixture_paused.erl new file mode 100644 index 00000000..4c30c326 --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_fixture_paused.erl @@ -0,0 +1,16 @@ +%% Test-only module interpreted by the debugger so that a process can be stopped +%% at a breakpoint (see mcp_server_SUITE:paused_process_is_reported_from_debugger_evidence). +-module(mcp_fixture_paused). + +-export([run/0, handler/1, break_line/0]). + +run() -> + X = 1, + Y = X + 1, + Y. + +%% called by the debugger when an interpreted process reaches a breakpoint +handler(_Pid) -> ok. + +%% line of `Y = X + 1` in run/0 +break_line() -> 9. diff --git a/apps/erlangbridge/test/mcp/mcp_fixture_sup.erl b/apps/erlangbridge/test/mcp/mcp_fixture_sup.erl new file mode 100644 index 00000000..75954800 --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_fixture_sup.erl @@ -0,0 +1,67 @@ +%% Test-only OTP fixture for the MCP topology inspector. Never part of production. +%% +%% Expected topology (independent of inspector output): +%% +%% mcp_fx_root (supervisor, app root) +%% |- worker_a registered mcp_fx_worker_a, links to an unsupervised orphan +%% |- mcp_fx_sub_sup supervisor +%% | |- worker_b unregistered +%% | `- worker_c registered mcp_fx_worker_c, owns ETS mcp_fx_orders (public) +%% | and mcp_fx_private (private) +%% `- mcp_fx_dyn_sup supervisor (simple_one_for_one), N dynamic workers +%% +%% Forbidden data: every SENTINEL_* string below must never appear in a result. +-module(mcp_fixture_sup). +-behaviour(supervisor). +-behaviour(application). + +-export([start/2, stop/1, init/1]). +-export([start_fixture/0, stop_fixture/0, add_dynamic/1, sentinel/0]). + +sentinel() -> "SENTINEL". + +start(_Type, _Args) -> + supervisor:start_link({local, mcp_fx_root}, ?MODULE, root). + +stop(_State) -> ok. + +%% Load and start the fixture as a real OTP application. +start_fixture() -> + App = {application, mcp_fixture_app, + [{description, "mcp fixture"}, {vsn, "9.9.9"}, + {modules, [mcp_fixture_sup, mcp_fixture_worker]}, + {registered, [mcp_fx_root]}, + {applications, [kernel, stdlib]}, + {env, [{secret, "SENTINEL_ENV"}]}, + {mod, {mcp_fixture_sup, []}}]}, + ok = application:load(App), + ok = application:start(mcp_fixture_app, temporary), + ok. + +stop_fixture() -> + catch application:stop(mcp_fixture_app), + catch application:unload(mcp_fixture_app), + ok. + +add_dynamic(N) -> + [{ok, _} = supervisor:start_child(mcp_fx_dyn_sup, [{dynamic_worker, I}]) || I <- lists:seq(1, N)], + ok. + +init(root) -> + Children = [#{id => worker_a, start => {mcp_fixture_worker, start_link, [mcp_fx_worker_a, {"SENTINEL_START_ARG", orphan}]}, + restart => permanent, shutdown => 1000, type => worker, modules => [mcp_fixture_worker]}, + #{id => mcp_fx_sub_sup, start => {supervisor, start_link, [{local, mcp_fx_sub_sup}, ?MODULE, sub]}, + restart => permanent, shutdown => infinity, type => supervisor, modules => [?MODULE]}, + #{id => mcp_fx_dyn_sup, start => {supervisor, start_link, [{local, mcp_fx_dyn_sup}, ?MODULE, dyn]}, + restart => permanent, shutdown => infinity, type => supervisor, modules => [?MODULE]}], + {ok, {#{strategy => one_for_one, intensity => 10, period => 5}, Children}}; +init(sub) -> + Children = [#{id => worker_b, start => {mcp_fixture_worker, start_link, [undefined, {"SENTINEL_START_ARG", plain}]}, + restart => transient, shutdown => 2000, type => worker, modules => [mcp_fixture_worker]}, + #{id => worker_c, start => {mcp_fixture_worker, start_link, [mcp_fx_worker_c, {"SENTINEL_START_ARG", tables}]}, + restart => permanent, shutdown => 1000, type => worker, modules => [mcp_fixture_worker]}], + {ok, {#{strategy => one_for_one, intensity => 10, period => 5}, Children}}; +init(dyn) -> + Child = #{id => dyn, start => {mcp_fixture_worker, start_link, [undefined]}, + restart => temporary, shutdown => 1000, type => worker, modules => [mcp_fixture_worker]}, + {ok, {#{strategy => simple_one_for_one, intensity => 10, period => 5}, [Child]}}. diff --git a/apps/erlangbridge/test/mcp/mcp_fixture_worker.erl b/apps/erlangbridge/test/mcp/mcp_fixture_worker.erl new file mode 100644 index 00000000..ae867341 --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_fixture_worker.erl @@ -0,0 +1,38 @@ +%% Test-only worker of the MCP fixture; holds forbidden sentinel data everywhere +%% the inspector must never look (state, dictionary, ETS objects, mailbox). +-module(mcp_fixture_worker). +-behaviour(gen_server). + +-export([start_link/1, start_link/2]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2]). + +start_link(Arg) -> start_link(undefined, Arg). + +start_link(undefined, Arg) -> + gen_server:start_link(?MODULE, Arg, []); +start_link(Name, Arg) -> + gen_server:start_link({local, Name}, ?MODULE, {Name, Arg}, []). + +init({mcp_fx_worker_a, _}) -> + put(secret, "SENTINEL_PDICT"), + %% a linked but unsupervised process (registered) + Orphan = spawn_link(fun() -> + register(mcp_fx_orphan, self()), + receive stop -> ok end + end), + {ok, #{secret => "SENTINEL_STATE", orphan => Orphan}}; +init({mcp_fx_worker_c, _}) -> + put(secret, "SENTINEL_PDICT"), + catch ets:delete(mcp_fx_orders), + ets:new(mcp_fx_orders, [named_table, public, set]), + ets:insert(mcp_fx_orders, {"SENTINEL_ETS_KEY", "SENTINEL_ETS_VALUE"}), + ets:new(mcp_fx_private, [named_table, private, set]), + ets:insert(mcp_fx_private, {"SENTINEL_PRIVATE_KEY", "SENTINEL_PRIVATE_VALUE"}), + {ok, #{secret => "SENTINEL_STATE"}}; +init(_) -> + put(secret, "SENTINEL_PDICT"), + {ok, #{secret => "SENTINEL_STATE"}}. + +handle_call(_, _, S) -> {reply, ok, S}. +handle_cast(_, S) -> {noreply, S}. +handle_info(_, S) -> {noreply, S}. diff --git a/apps/erlangbridge/test/mcp/mcp_policy_SUITE.erl b/apps/erlangbridge/test/mcp/mcp_policy_SUITE.erl new file mode 100644 index 00000000..079f2168 --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_policy_SUITE.erl @@ -0,0 +1,311 @@ +-module(mcp_policy_SUITE). + +%% Settings + rebar.config policy resolution (mcp_policy): defaults, validation, +%% loopback enforcement, project lookup boundaries, non-evaluation, ownership. + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-compile([export_all, nowarn_export_all]). + +all() -> + [disabled_by_default_and_when_not_true, + untrusted_workspace_disables, + defaults_without_project_file, + loopback_hosts_accepted, + non_loopback_hosts_rejected, + port_range_validated, + project_policy_normalized, + explicit_empty_allowlist_enables_no_tool, + project_cannot_set_activation_or_binding, + project_limits_cannot_exceed_ceilings, + developer_tier_is_opt_in_and_ets_all_is_accepted, + default_keyword_follows_the_built_in_tool_set, + non_ascii_workspace_path_still_finds_the_project_policy, + token_with_trailing_newline_is_rejected, + invalid_and_duplicate_project_terms, + profile_only_policy_reported, + malformed_project_file_disables, + script_is_never_executed, + umbrella_uses_enclosing_project_file, + lookup_never_leaves_the_workspace_folder, + oversized_project_file_rejected, + json_round_trip_and_authoritative_validation, + only_project_policy_is_returned, + fixed_token_is_validated]. + +init_per_testcase(Case, Config) -> + Dir = filename:join(?config(priv_dir, Config), atom_to_list(Case)), + ok = filelib:ensure_dir(filename:join(Dir, "x")), + [{root, Dir} | Config]. + +%%%%%%%%%%% +%% cases %% +%%%%%%%%%%% + +input(Config, Extra) -> + maps:merge(#{<<"enabled">> => true, <<"trusted">> => true, + <<"cwd">> => list_to_binary(?config(root, Config)), + <<"root">> => list_to_binary(?config(root, Config))}, Extra). + +write_rebar(Config, Text) -> + ok = file:write_file(filename:join(?config(root, Config), "rebar.config"), Text). + +disabled_by_default_and_when_not_true(Config) -> + ?assertEqual(disabled, mcp_policy:resolve(#{})), + ?assertEqual(disabled, mcp_policy:resolve(input(Config, #{<<"enabled">> => false}))), + ?assertEqual(disabled, mcp_policy:resolve(input(Config, #{<<"enabled">> => <<"true">>}))), + ?assertEqual(disabled, mcp_policy:resolve(input(Config, #{<<"enabled">> => 1}))). + +untrusted_workspace_disables(Config) -> + ?assertMatch({error, "MCP is disabled: the workspace is not trusted"}, + mcp_policy:resolve(input(Config, #{<<"trusted">> => false}))). + +defaults_without_project_file(Config) -> + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual("127.0.0.1", maps:get(host, C)), + ?assertEqual(0, maps:get(port, C)), + ?assertEqual(mcp_policy:default_tools(), maps:get(allowed_tools, C)), + ?assertEqual([], [T || T <- [<<"process_state">>, <<"mailbox_sample">>, <<"ets_sample">>], lists:member(T, maps:get(allowed_tools, C))]), + ?assertEqual([], maps:get(allowed_ets_tables, C)), + ?assertEqual(mcp_policy:default_limits(), maps:get(limits, C)), + ?assertEqual(13, length(maps:get(allowed_tools, C))), + %% a project without an mcp term uses the same built-in policy + write_rebar(Config, "{erl_opts, [debug_info]}.\n{deps, []}.\n"), + ?assertEqual({ok, C}, mcp_policy:resolve(input(Config, #{}))). + +loopback_hosts_accepted(Config) -> + [?assertMatch({ok, #{host := Expected}}, mcp_policy:resolve(input(Config, #{<<"host">> => Host}))) + || {Host, Expected} <- [{<<"127.0.0.1">>, "127.0.0.1"}, + {<<"127.0.0.2">>, "127.0.0.2"}, + {<<"::1">>, "::1"}]], + %% a name is resolved and every address must be loopback + ?assertMatch({ok, #{host := _}}, mcp_policy:resolve(input(Config, #{<<"host">> => <<"localhost">>}))). + +non_loopback_hosts_rejected(Config) -> + [?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{<<"host">> => Host}))) + || Host <- [<<"0.0.0.0">>, <<"::">>, <<"192.168.1.10">>, <<"10.0.0.1">>, <<"8.8.8.8">>, + <<"">>, <<"*">>, <<"[::]">>, 42, null]]. + +port_range_validated(Config) -> + [?assertMatch({ok, #{port := P}}, mcp_policy:resolve(input(Config, #{<<"port">> => P}))) + || P <- [0, 1, 65535]], + [?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{<<"port">> => P}))) + || P <- [-1, 65536, <<"80">>, 1.5, null]]. + +project_policy_normalized(Config) -> + write_rebar(Config, + "{erl_opts, [debug_info]}.\n" + "{mcp, [{allowed_tools, [<<\"runtime_summary\">>, <<\"ets_tables\">>]},\n" + " {allowed_ets_tables, [<<\"orders_index\">>]},\n" + " {limits, [{max_items, 10}, {request_timeout_ms, 500}]}]}.\n"), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual([<<"runtime_summary">>, <<"ets_tables">>], maps:get(allowed_tools, C)), + ?assertEqual([<<"orders_index">>], maps:get(allowed_ets_tables, C)), + ?assertEqual(10, mcp_policy:limit(max_items, C)), + ?assertEqual(500, mcp_policy:limit(request_timeout_ms, C)), + %% untouched limits keep their defaults + ?assertEqual(16384, mcp_policy:limit(max_request_bytes, C)), + %% settings own activation/binding + ?assertMatch({ok, #{host := "127.0.0.1", port := 4711}}, + mcp_policy:resolve(input(Config, #{<<"port">> => 4711}))). + +explicit_empty_allowlist_enables_no_tool(Config) -> + write_rebar(Config, "{mcp, [{allowed_tools, []}]}.\n"), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual([], maps:get(allowed_tools, C)), + [?assertNot(mcp_policy:tool_allowed(T, C)) || T <- mcp_policy:all_tools()], + ?assertEqual([], mcp_tools:list(C)). + +project_cannot_set_activation_or_binding(Config) -> + [begin + write_rebar(Config, lists:flatten(io_lib:format("{mcp, [~s]}.\n", [Term]))), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Term) + end || Term <- ["{enabled, true}", "{required, true}", "{host, <<\"0.0.0.0\">>}", "{port, 1234}", + "{transport, streamable_http}", "{auth_token, <<\"x\">>}", "{token, <<\"x\">>}", + "{credentials, []}", "{cookie, abc}"]]. + +project_limits_cannot_exceed_ceilings(Config) -> + [begin + write_rebar(Config, lists:flatten(io_lib:format("{mcp, [{limits, [~s]}]}.\n", [Term]))), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Term) + end || Term <- ["{max_items, 5001}", "{max_items, 0}", "{max_items, -1}", "{max_items, 1.5}", + "{max_result_bytes, 2097153}", "{request_timeout_ms, 15001}", "{max_concurrency, 9}", + "{unknown_limit, 1}", "{max_items, <<\"5\">>}", + %% unusable combinations: error envelopes must fit + "{max_result_bytes, 100}", "{max_request_bytes, 100}", + "{max_result_bytes, 8192}, {max_collection_bytes, 4096}"]], + write_rebar(Config, "{mcp, [{limits, [{max_items, 1}, {max_result_bytes, 4096}, {max_collection_bytes, 4096}, {max_binary_bytes, 1024}]}]}.\n"), + ?assertMatch({ok, _}, mcp_policy:resolve(input(Config, #{}))), + %% raising a limit above its default is allowed up to the ceiling + write_rebar(Config, "{mcp, [{limits, [{max_items, 5000}, {max_result_bytes, 2097152}, {max_collection_bytes, 8388608}," + " {request_timeout_ms, 15000}, {collection_ttl_ms, 600000}, {max_collections, 16}]}]}.\n"), + {ok, Raised} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual(5000, mcp_policy:limit(max_items, Raised)), + ?assertEqual(600000, mcp_policy:limit(collection_ttl_ms, Raised)). + +developer_tier_is_opt_in_and_ets_all_is_accepted(Config) -> + Dev = [<<"process_state">>, <<"mailbox_sample">>, <<"ets_sample">>], + {ok, Default} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual([], [T || T <- Dev, mcp_policy:tool_allowed(T, Default)]), + ?assert(lists:all(fun(T) -> lists:member(T, mcp_policy:all_tools()) end, Dev)), + write_rebar(Config, "{mcp, [{allowed_tools, [<<\"process_state\">>, <<\"ets_sample\">>]}, {allowed_ets_tables, all}]}.\n"), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assert(mcp_policy:tool_allowed(<<"process_state">>, C)), + ?assertNot(mcp_policy:tool_allowed(<<"mailbox_sample">>, C)), + ?assertEqual(all, maps:get(allowed_ets_tables, C)), + ?assert(mcp_policy:ets_table_allowed(<<"anything">>, C)), + %% crosses to the target and back unchanged + ?assertEqual({ok, C}, mcp_policy:from_json_map(mcp_policy:to_json_map(C))), + ?assertNot(mcp_policy:ets_table_allowed(<<"x">>, Default)), + [begin + write_rebar(Config, Text), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Text) + end || Text <- ["{mcp, [{allowed_ets_tables, everything}]}.\n", "{mcp, [{allowed_tools, [<<\"process_dump\">>]}]}.\n"]]. + +default_keyword_follows_the_built_in_tool_set(Config) -> + Default = mcp_policy:default_tools(), + Tools = fun(Text) -> + write_rebar(Config, "{mcp, [{allowed_tools, " ++ Text ++ "}]}.\n"), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + maps:get(allowed_tools, C) + end, + ?assertEqual(Default, Tools("default")), + ?assertEqual(Default, Tools("[default]")), + %% the built-in set plus a developer tool; an explicit tool is never listed twice + ?assertEqual(lists:sort([<<"process_state">> | Default]), lists:sort(Tools("[default, <<\"process_state\">>]"))), + ?assertEqual(lists:sort(Default), lists:sort(Tools("[default, <<\"runtime_summary\">>]"))), + [begin + write_rebar(Config, Text), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Text) + end || Text <- ["{mcp, [{allowed_tools, [default, default]}]}.\n", "{mcp, [{allowed_tools, [defaults]}]}.\n", + "{mcp, [{allowed_tools, all}]}.\n"]]. + +non_ascii_workspace_path_still_finds_the_project_policy(Config) -> + Dir = filename:join(?config(priv_dir, Config), "José_проект"), + ok = filelib:ensure_dir(filename:join(Dir, "x")), + ok = file:write_file(filename:join(Dir, "rebar.config"), "{mcp, [{allowed_tools, [<<\"runtime_summary\">>]}]}.\n"), + Bin = unicode:characters_to_binary(Dir), + {ok, C} = mcp_policy:resolve(#{<<"enabled">> => true, <<"trusted">> => true, <<"cwd">> => Bin, <<"root">> => Bin}), + ?assertEqual([<<"runtime_summary">>], maps:get(allowed_tools, C)). + +token_with_trailing_newline_is_rejected(_Config) -> + ?assertEqual(ok, mcp_policy:validate_token(<<"abcdefghijklmnop">>)), + ?assertMatch({error, _}, mcp_policy:validate_token(<<"abcdefghijklmnop\n">>)). + +invalid_and_duplicate_project_terms(Config) -> + [begin + write_rebar(Config, Text), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Text) + end || Text <- ["{mcp, [{allowed_tools, [<<\"eval\">>]}]}.\n", + "{mcp, [{allowed_tools, [<<\"runtime_summary\">>, <<\"runtime_summary\">>]}]}.\n", + "{mcp, [{allowed_tools, [runtime_summary]}]}.\n", + "{mcp, [{allowed_tools, []}, {allowed_tools, []}]}.\n", + "{mcp, [{allowed_ets_tables, [foo]}]}.\n", + "{mcp, [{allowed_ets_tables, [<<\"a\">>, <<\"a\">>]}]}.\n", + "{mcp, [{whatever, 1}]}.\n", + "{mcp, [{limits, [{max_items, 5}, {max_items, 6}]}]}.\n", + "{mcp, notalist}.\n", + "{mcp, [{allowed_tools, []}]}.\n{mcp, [{allowed_tools, []}]}.\n"]]. + +profile_only_policy_reported(Config) -> + write_rebar(Config, "{profiles, [{test, [{mcp, [{allowed_tools, []}]}]}]}.\n"), + {error, Msg} = mcp_policy:resolve(input(Config, #{})), + ?assertNotEqual(nomatch, string:find(Msg, "profile")), + %% a top-level term wins: the profile term is not merged + write_rebar(Config, "{mcp, []}.\n{profiles, [{test, [{mcp, [{allowed_tools, []}]}]}]}.\n"), + ?assertMatch({ok, #{allowed_tools := [_ | _]}}, mcp_policy:resolve(input(Config, #{}))). + +malformed_project_file_disables(Config) -> + [begin + write_rebar(Config, Text), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{})), Text) + end || Text <- ["{mcp, [{allowed_tools, [}.\n", + "{mcp, [].\n", + "not a term at all\n", + "{mcp, [{limits, [{max_items, 1 + 1}]}]}.\n", + "{mcp, foo(bar)}.\n", + <<255, 254, 253>>]]. + +script_is_never_executed(Config) -> + Marker = filename:join(?config(root, Config), "script_ran"), + write_rebar(Config, "{mcp, [{allowed_tools, [<<\"runtime_summary\">>]}]}.\n"), + ok = file:write_file(filename:join(?config(root, Config), "rebar.config.script"), + io_lib:format("file:write_file(~p, <<\"ran\">>), CONFIG.~n", [Marker])), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual([<<"runtime_summary">>], maps:get(allowed_tools, C)), + ?assertNot(filelib:is_file(Marker)), + %% the parser does not evaluate function calls placed in the file either + write_rebar(Config, io_lib:format("{mcp, [{allowed_tools, [list_to_binary(\"x\")]}]}.~n{x, file:write_file(~p, <<\"x\">>)}.~n", [Marker])), + ?assertMatch({error, _}, mcp_policy:resolve(input(Config, #{}))), + ?assertNot(filelib:is_file(Marker)). + +umbrella_uses_enclosing_project_file(Config) -> + Root = ?config(root, Config), + App = filename:join([Root, "apps", "myapp"]), + ok = filelib:ensure_dir(filename:join(App, "x")), + write_rebar(Config, "{mcp, [{allowed_tools, [<<\"debug_session\">>]}]}.\n"), + Input = (input(Config, #{}))#{<<"cwd">> => list_to_binary(App)}, + ?assertMatch({ok, #{allowed_tools := [<<"debug_session">>]}}, mcp_policy:resolve(Input)), + %% the application's own file is the nearest one + ok = file:write_file(filename:join(App, "rebar.config"), "{mcp, [{allowed_tools, [<<\"ets_tables\">>]}]}.\n"), + ?assertMatch({ok, #{allowed_tools := [<<"ets_tables">>]}}, mcp_policy:resolve(Input)). + +lookup_never_leaves_the_workspace_folder(Config) -> + Base = ?config(root, Config), + Root = filename:join(Base, "folder"), + Sibling = filename:join(Base, "folder2"), + Outside = Base, + [ok = filelib:ensure_dir(filename:join(D, "x")) || D <- [Root, Sibling]], + %% a permissive-looking file above the folder must not be consulted + ok = file:write_file(filename:join(Outside, "rebar.config"), "{mcp, [{allowed_tools, []}]}.\n"), + Input = #{<<"enabled">> => true, <<"trusted">> => true, + <<"cwd">> => list_to_binary(Root), <<"root">> => list_to_binary(Root)}, + ?assertMatch({ok, #{allowed_tools := [_, _ | _]}}, mcp_policy:resolve(Input)), + %% cwd outside the folder (also a string-prefix sibling) disables MCP + ?assertMatch({error, _}, mcp_policy:resolve(Input#{<<"cwd">> => list_to_binary(Sibling)})), + ?assertMatch({error, _}, mcp_policy:resolve(Input#{<<"cwd">> => list_to_binary(Outside)})), + ?assertMatch({error, _}, mcp_policy:resolve(Input#{<<"cwd">> => list_to_binary(filename:join(Root, "../folder2"))})), + ?assertMatch({error, _}, mcp_policy:resolve(Input#{<<"cwd">> => <<>>})). + +oversized_project_file_rejected(Config) -> + Big = lists:duplicate(300000, $\s), + write_rebar(Config, ["{mcp, []}.\n", Big]), + ?assertMatch({error, "rebar.config is too large" ++ _}, mcp_policy:resolve(input(Config, #{}))). + +json_round_trip_and_authoritative_validation(Config) -> + {ok, C} = mcp_policy:resolve(input(Config, #{<<"port">> => 5000})), + Json = mcp_policy:to_json_map(C), + {ok, Encoded} = vscode_jsone:encode(Json), + {ok, Decoded, _} = vscode_jsone_decode:decode(iolist_to_binary(Encoded)), + ?assertEqual({ok, C}, mcp_policy:from_json_map(Decoded)), + %% the target validates again: tampered configuration is refused + [?assertMatch({error, _}, mcp_policy:from_json_map(Bad)) + || Bad <- [Decoded#{<<"host">> => <<"0.0.0.0">>}, + Decoded#{<<"port">> => 70000}, + Decoded#{<<"authToken">> => <<"x">>}, + Decoded#{<<"required">> => true}, + Decoded#{<<"allowedTools">> => [<<"eval">>]}, + Decoded#{<<"limits">> => (maps:get(<<"limits">>, Decoded))#{<<"maxItems">> => 100000}}, + Decoded#{<<"limits">> => (maps:get(<<"limits">>, Decoded))#{<<"extra">> => 1}}, + maps:remove(<<"limits">>, Decoded), + #{}]], + ?assertMatch({error, _}, mcp_sup:start_session(C#{host => "0.0.0.0"}, launch)), + ?assertNot(mcp_sup:active()). + +only_project_policy_is_returned(Config) -> + %% other project terms never leak into the normalized policy + write_rebar(Config, "{deps, [{secret_dep, \"SENTINEL_DEP\"}]}.\n{mcp, []}.\n"), + {ok, C} = mcp_policy:resolve(input(Config, #{})), + ?assertEqual(nomatch, re:run(io_lib:format("~p", [C]), "SENTINEL")), + ?assertEqual([allowed_ets_tables, allowed_tools, host, limits, port], lists:sort(maps:keys(C))). + +fixed_token_is_validated(Config) -> + {ok, C} = mcp_policy:resolve(input(Config, #{})), + Base = mcp_policy:to_json_map(C), + ?assertEqual(ok, mcp_policy:validate_token(<<"dev-token_0123456789.AZ~">>)), + [?assertMatch({error, _}, mcp_policy:validate_token(T)) + || T <- [<<"short">>, <<>>, binary:copy(<<"a">>, 129), <<"has space 0123456789">>, + <<"quote\"0123456789abc">>, <<"sl/ash0123456789abc">>, 42, undefined]], + ?assertEqual({ok, C}, mcp_policy:from_json_map(Base#{<<"authToken">> => <<"dev-token_0123456789">>})), + ?assertMatch({error, _}, mcp_policy:from_json_map(Base#{<<"authToken">> => <<"x">>})). diff --git a/apps/erlangbridge/test/mcp/mcp_server_SUITE.erl b/apps/erlangbridge/test/mcp/mcp_server_SUITE.erl new file mode 100644 index 00000000..b375648d --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_server_SUITE.erl @@ -0,0 +1,1508 @@ +-module(mcp_server_SUITE). + +%% End-to-end tests of the embedded MCP inspector over its real HTTP endpoint, +%% against a deterministic OTP fixture (mcp_fixture_sup) whose expected +%% topology is defined independently of the inspector output. +%% +%% The fixture contains SENTINEL_* strings in every place the inspector must +%% never read (process state/dictionary/mailbox, ETS objects, child start +%% arguments, application environment): no result may contain "SENTINEL". + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-compile([export_all, nowarn_export_all]). + +all() -> + [unauthenticated_and_bad_tokens_rejected, + host_and_origin_validated, + method_content_type_and_size_enforced, + initialize_negotiates_and_lists_only_readonly_tools, + initialize_tells_the_agent_which_tools_are_disabled, + initialize_is_silent_when_nothing_is_disabled, + allowlist_enforced, + arguments_are_strict, + runtime_summary_redacts_node_host, + application_overview_discovers_fixture_without_names, + supervision_tree_matches_independent_expected_graph, + linked_unsupervised_process_is_not_supervised, + process_info_inputs_are_safe, + registered_processes_membership_evidence, + ets_tables_metadata_only_for_approved_tables, + top_processes_ranks_and_is_bounded, + supervision_tree_reports_child_counts, + mermaid_format_renders_the_returned_edges, + topology_overview_maps_one_application_in_one_call, + topology_overview_leaves_out_parts_the_policy_denies, + prompt_guides_an_agent_and_checks_its_argument, + changes_since_reports_replaced_and_removed_children, + top_ports_reports_drivers_and_owners_only, + process_groups_aggregates_anonymous_processes_in_a_fixed_size_answer, + ets_summary_aggregates_per_owner_without_names, + ets_summary_counts_only_approved_tables, + developer_tools_are_off_by_default, + process_state_is_bounded_redacted_and_never_for_supervisors, + mailbox_sample_returns_the_oldest_messages_redacted, + ets_sample_only_for_approved_non_private_tables, + paused_process_is_reported_from_debugger_evidence, + restart_changes_identity, + pagination_cursors_and_retention, + collection_byte_budget_is_partial_and_non_resumable, + blocked_supervisor_yields_partial_result, + concurrency_is_bounded, + stop_closes_endpoint_and_invalidates_credentials, + fixed_port_conflict_disables_without_rebinding, + renewal_keeps_the_session_alive_and_lease_expiry_stops_it, + no_forbidden_data_no_atoms_no_stacktraces, + logs_never_contain_token_or_runtime_data, + lsp_application_never_starts_the_inspector, + journal_reports_request_metadata_only, + agent_summary_is_compact]. + +init_per_suite(Config) -> + ok = mcp_fixture_sup:start_fixture(), + Mailbox = spawn(fun() -> register(mcp_fx_mailbox, self()), receive never -> ok end end), + Mailbox ! "SENTINEL_MAILBOX", + [{mailbox, Mailbox} | Config]. + +end_per_suite(Config) -> + exit(?config(mailbox, Config), kill), + mcp_fixture_sup:stop_fixture(), + ok. + +init_per_testcase(lsp_application_never_starts_the_inspector, Config) -> Config; +init_per_testcase(fixed_port_conflict_disables_without_rebinding, Config) -> Config; +init_per_testcase(renewal_keeps_the_session_alive_and_lease_expiry_stops_it, Config) -> Config; +init_per_testcase(Case, Config) -> + Cfg = test_config(Case), + Self = self(), + Opts = case Case of + journal_reports_request_metadata_only -> #{notify => fun(M) -> Self ! {journal, M} end}; + _ -> #{} + end, + {ok, Info} = mcp_sup:start_session(Cfg, launch, undefined, Opts), + [{cfg, Cfg}, {info, Info}, {port, maps:get(port, Info)}, {token, maps:get(token, Info)} | Config]. + +end_per_testcase(_Case, _Config) -> + catch sys:resume(mcp_fx_sub_sup), + mcp_sup:stop_session(), + ok. + +test_config(Case) -> + Base = mcp_policy:defaults(), + L = maps:get(limits, Base), + Ets = [<<"mcp_fx_orders">>, <<"mcp_fx_private">>, <<"mcp_fx_missing_table_zzz">>], + Base1 = Base#{allowed_ets_tables => Ets}, + case Case of + allowlist_enforced -> + Base1#{allowed_tools => [<<"runtime_summary">>, <<"ets_tables">>]}; + initialize_tells_the_agent_which_tools_are_disabled -> + Base1#{allowed_tools => [<<"runtime_summary">>, <<"ets_tables">>]}; + ets_summary_aggregates_per_owner_without_names -> + Base1#{allowed_ets_tables => all}; + Dev when Dev =:= process_state_is_bounded_redacted_and_never_for_supervisors; + Dev =:= mailbox_sample_returns_the_oldest_messages_redacted; + Dev =:= ets_sample_only_for_approved_non_private_tables -> + Base1#{allowed_tools => mcp_policy:all_tools()}; + topology_overview_leaves_out_parts_the_policy_denies -> + Base1#{allowed_tools => [<<"topology_overview">>, <<"supervision_tree">>, <<"application_overview">>]}; + pagination_cursors_and_retention -> + Base1#{limits => L#{max_items => 10, max_collections => 2, collection_ttl_ms => 1500}}; + collection_byte_budget_is_partial_and_non_resumable -> + Base1#{limits => L#{max_result_bytes => 4096, max_collection_bytes => 4096, max_binary_bytes => 512}}; + blocked_supervisor_yields_partial_result -> + Base1#{limits => L#{request_timeout_ms => 800}}; + concurrency_is_bounded -> + Base1#{limits => L#{request_timeout_ms => 1500, max_concurrency => 1}}; + _ -> Base1 + end. + +%%%%%%%%%%%%%%%%%%%% +%% HTTP client %% +%%%%%%%%%%%%%%%%%%%% + +%% -> {Code, HeadersMap, Body} +http(Port, Method, Headers, Body) -> + http(Port, Method, "/mcp", Headers, Body, undefined). + +http(Port, Method, Path, Headers, Body, ContentLength) -> + {ok, S} = gen_tcp:connect({127, 0, 0, 1}, Port, [binary, {active, false}]), + Len = case ContentLength of undefined -> byte_size(Body); N -> N end, + Lines = [[K, ": ", V, "\r\n"] || {K, V} <- Headers], + LenLine = case Len of skip -> []; _ -> ["Content-Length: ", integer_to_list(Len), "\r\n"] end, + ok = gen_tcp:send(S, [Method, " ", Path, " HTTP/1.1\r\n", Lines, LenLine, "\r\n", Body]), + Raw = recv_all(S, <<>>), + gen_tcp:close(S), + parse_response(Raw). + +recv_all(S, Acc) -> + case gen_tcp:recv(S, 0, 10000) of + {ok, D} -> recv_all(S, <>); + {error, _} -> Acc + end. + +parse_response(<<>>) -> {0, #{}, <<>>}; +parse_response(Raw) -> + [Head, Body] = case binary:split(Raw, <<"\r\n\r\n">>) of + [H, B] -> [H, B]; + [H] -> [H, <<>>] + end, + [Status | HLines] = binary:split(Head, <<"\r\n">>, [global]), + [_, Code | _] = binary:split(Status, <<" ">>, [global]), + Hs = maps:from_list([case binary:split(L, <<": ">>) of + [K, V] -> {string:lowercase(K), V}; + [K] -> {string:lowercase(K), <<>>} + end || L <- HLines]), + {binary_to_integer(Code), Hs, Body}. + +auth(Config) -> {"Authorization", ["Bearer ", ?config(token, Config)]}. + +std_headers(Config) -> + Port = ?config(port, Config), + [{"Host", ["127.0.0.1:", integer_to_list(Port)]}, auth(Config), + {"Content-Type", "application/json"}]. + +%% JSON-RPC over the real endpoint -> decoded response map +rpc(Config, Method, Params) -> + rpc(Config, Method, Params, 1). + +rpc(Config, Method, Params, Id) -> + Req = #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => Id, <<"method">> => Method, <<"params">> => Params}, + {ok, Json} = vscode_jsone:encode(Req), + {200, _, Body} = http(?config(port, Config), "POST", std_headers(Config), iolist_to_binary(Json)), + decode(Body). + +decode(Bin) -> + {ok, T, <<>>} = vscode_jsone_decode:decode(Bin), + T. + +%% tools/call -> {ok, Structured} | {tool_error, Structured} | {rpc_error, Code, Message} +%% The contract tests check every field, so graph tools are called with detail=full +%% unless the case chooses the detail itself (see agent_summary_is_compact). +call(Config, Tool, Args0) when is_map(Args0) -> + Graph = [<<"application_overview">>, <<"supervision_tree">>, <<"registered_processes">>, + <<"process_info">>, <<"ets_tables">>, <<"top_processes">>, <<"topology_overview">>, <<"changes_since">>, <<"top_ports">>, <<"ets_summary">>], + Args = case lists:member(Tool, Graph) andalso not maps:is_key(<<"detail">>, Args0) of + true -> Args0#{<<"detail">> => <<"full">>}; + false -> Args0 + end, + raw_call(Config, Tool, Args); +call(Config, Tool, Args) -> + raw_call(Config, Tool, Args). + +raw_call(Config, Tool, Args) -> + case rpc(Config, <<"tools/call">>, #{<<"name">> => Tool, <<"arguments">> => Args}) of + #{<<"result">> := #{<<"isError">> := false, <<"structuredContent">> := S, <<"content">> := [#{<<"text">> := Text}]}} -> + %% the text content is the same JSON as the structured content + ?assertEqual(S, decode(Text)), + {ok, S}; + #{<<"result">> := #{<<"isError">> := true, <<"structuredContent">> := S}} -> + {tool_error, S}; + #{<<"error">> := #{<<"code">> := C, <<"message">> := M}} -> + {rpc_error, C, M} + end. + +ok_call(Config, Tool, Args) -> + {ok, S} = call(Config, Tool, Args), + S. + +%% every page of a paginated tool call +all_pages(Config, Tool, Args) -> + S = ok_call(Config, Tool, Args), + collect_pages(Config, Tool, Args, S, [S]). + +collect_pages(Config, Tool, Args, #{<<"nextCursor">> := Cursor}, Acc) when is_binary(Cursor) -> + S = ok_call(Config, Tool, Args#{<<"cursor">> => Cursor}), + collect_pages(Config, Tool, Args, S, [S | Acc]); +collect_pages(_, _, _, _, Acc) -> + lists:reverse(Acc). + +entities(Pages) -> lists:append([maps:get(<<"entities">>, P) || P <- Pages]). +relationships(Pages) -> lists:append([maps:get(<<"relationships">>, P) || P <- Pages]). + +by_name(Name, Ents) -> + [E || #{<<"name">> := N} = E <- Ents, N =:= Name]. + +ent(Name, Ents) -> + [E] = by_name(Name, Ents), + E. + +label(#{<<"childId">> := C}) -> C; +label(#{<<"name">> := N}) when is_binary(N) -> N; +label(#{<<"pid">> := P}) -> P. + +id_of(#{<<"id">> := I}) -> I. + +fixture_app_id(Config) -> + S = ok_call(Config, <<"application_overview">>, #{}), + id_of(ent(<<"mcp_fixture_app">>, maps:get(<<"entities">>, S))). + +fixture_root_id(Config) -> + S = ok_call(Config, <<"application_overview">>, #{}), + [RootId] = maps:get(<<"roots">>, ent(<<"mcp_fixture_app">>, maps:get(<<"entities">>, S))), + RootId. + +%% {ParentLabel, ChildLabel} of every supervises edge +supervises_edges(Pages) -> + Ents = entities(Pages), + Labels = maps:from_list([{id_of(E), label(E)} || E <- Ents]), + lists:sort([{maps:get(F, Labels), maps:get(T, Labels)} + || #{<<"type">> := <<"supervises">>, <<"from">> := F, <<"to">> := T} <- relationships(Pages)]). + +%%%%%%%%%%% +%% cases %% +%%%%%%%%%%% + +unauthenticated_and_bad_tokens_rejected(Config) -> + Port = ?config(port, Config), + Tok = ?config(token, Config), + Body = <<"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}">>, + Base = [{"Host", ["127.0.0.1:", integer_to_list(Port)]}, {"Content-Type", "application/json"}], + {401, H, B} = http(Port, "POST", Base, Body), + ?assertEqual(<<"Bearer">>, maps:get(<<"www-authenticate">>, H)), + ?assertEqual(nomatch, binary:match(B, Tok)), + [?assertMatch({401, _, _}, http(Port, "POST", Base ++ [{"Authorization", V}], Body)) + || V <- ["Bearer wrong", ["Bearer ", Tok, "x"], ["Bearer ", binary:part(Tok, 0, byte_size(Tok) - 1)], + ["Basic ", Tok], Tok, "Bearer ", ["Bearer ", Tok]]], + %% the scheme is case-insensitive, the token is not + ?assertMatch({200, _, _}, http(Port, "POST", Base ++ [{"Authorization", ["bearer ", Tok]}], Body)), + ?assertMatch({401, _, _}, http(Port, "POST", Base ++ [{"Authorization", ["Bearer ", string:lowercase(Tok)]}], Body)), + %% duplicated Authorization header is refused + ?assertMatch({400, _, _}, http(Port, "POST", Base ++ [auth(Config), auth(Config)], Body)), + %% no request is processed before authentication: a body that is not JSON is 401, not 400 + ?assertMatch({401, _, _}, http(Port, "POST", Base, <<"not json">>)), + %% token entropy: at least 32 random bytes + ?assert(byte_size(Tok) >= 43). + +host_and_origin_validated(Config) -> + Port = ?config(port, Config), + Body = <<"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}">>, + Ok = [auth(Config), {"Content-Type", "application/json"}], + HostOk = {"Host", ["127.0.0.1:", integer_to_list(Port)]}, + ?assertMatch({200, _, _}, http(Port, "POST", [HostOk | Ok], Body)), + ?assertMatch({200, _, _}, http(Port, "POST", [{"Host", ["localhost:", integer_to_list(Port)]} | Ok], Body)), + [?assertMatch({403, _, _}, http(Port, "POST", [{"Host", H} | Ok], Body)) + || H <- ["evil.example.com", "evil.example.com:80", ["127.0.0.1:", integer_to_list(Port + 1)], "127.0.0.1", + ["0.0.0.0:", integer_to_list(Port)], ["[::1]:", integer_to_list(Port)]]], + ?assertMatch({403, _, _}, http(Port, "POST", Ok, Body)), + %% browser-origin attacks: any Origin that is not this loopback endpoint is refused + [?assertMatch({403, _, _}, http(Port, "POST", [HostOk, {"Origin", O} | Ok], Body)) + || O <- ["http://evil.example.com", "https://127.0.0.1", "null", "http://127.0.0.1:1", "file://"]], + ?assertMatch({200, _, _}, http(Port, "POST", [HostOk, {"Origin", ["http://127.0.0.1:", integer_to_list(Port)]} | Ok], Body)), + %% CORS is never enabled + {200, H, _} = http(Port, "POST", [HostOk | Ok], Body), + ?assertEqual([], [K || K <- maps:keys(H), binary:match(K, <<"access-control">>) =/= nomatch]). + +method_content_type_and_size_enforced(Config) -> + Port = ?config(port, Config), + Std = std_headers(Config), + Body = <<"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}">>, + [?assertMatch({405, _, _}, http(Port, M, Std, <<>>)) || M <- ["GET", "PUT", "DELETE", "PATCH", "OPTIONS"]], + ?assertMatch({404, _, _}, http(Port, "POST", "/other", Std, Body, undefined)), + ?assertMatch({404, _, _}, http(Port, "POST", "/mcp/", Std, Body, undefined)), + ?assertMatch({404, _, _}, http(Port, "POST", "/mcp?x=1", Std, Body, undefined)), + Bad = [{"Host", ["127.0.0.1:", integer_to_list(Port)]}, auth(Config)], + ?assertMatch({415, _, _}, http(Port, "POST", Bad ++ [{"Content-Type", "text/plain"}], Body)), + ?assertMatch({415, _, _}, http(Port, "POST", Bad, Body)), + ?assertMatch({200, _, _}, http(Port, "POST", Bad ++ [{"Content-Type", "application/json; charset=utf-8"}], Body)), + %% body cap is enforced before the body is read + ?assertMatch({413, _, _}, http(Port, "POST", "/mcp", Std, <<>>, 16385)), + ?assertMatch({413, _, _}, http(Port, "POST", "/mcp", Std, <<>>, 999999999)), + ?assertMatch({400, _, _}, http(Port, "POST", "/mcp", Std, <<>>, -1)), + ?assertMatch({411, _, _}, http(Port, "POST", "/mcp", Std ++ [{"Transfer-Encoding", "chunked"}], Body, skip)), + ?assertMatch({411, _, _}, http(Port, "POST", "/mcp", Std, Body, skip)), + %% malformed payloads + ?assertMatch({400, _, _}, http(Port, "POST", Std, <<"{not json">>)), + ?assertMatch({400, _, _}, http(Port, "POST", Std, <<"{\"jsonrpc\":\"2.0\"} trailing">>)), + {200, _, B1} = http(Port, "POST", Std, <<"[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]">>), + ?assertMatch(#{<<"error">> := #{<<"code">> := -32600}}, decode(B1)), + {200, _, B2} = http(Port, "POST", Std, <<"{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"ping\"}">>), + ?assertMatch(#{<<"error">> := #{<<"code">> := -32600}}, decode(B2)), + %% a notification is accepted without a body + ?assertMatch({202, _, <<>>}, http(Port, "POST", Std, <<"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}">>)), + %% slow / truncated body: the server does not hang and answers + ?assertMatch({400, _, _}, http(Port, "POST", "/mcp", Std, <<"{\"jsonrpc\"">>, 100)). + +initialize_negotiates_and_lists_only_readonly_tools(Config) -> + #{<<"result">> := Init} = rpc(Config, <<"initialize">>, #{<<"protocolVersion">> => <<"2025-06-18">>, + <<"capabilities">> => #{}, + <<"clientInfo">> => #{<<"name">> => <<"t">>, <<"version">> => <<"1">>}}), + ?assertEqual(<<"2025-06-18">>, maps:get(<<"protocolVersion">>, Init)), + ?assertEqual([<<"prompts">>, <<"tools">>], lists:sort(maps:keys(maps:get(<<"capabilities">>, Init)))), + %% an older client version is answered with the implemented revision + #{<<"result">> := Init2} = rpc(Config, <<"initialize">>, #{<<"protocolVersion">> => <<"2024-11-05">>}), + ?assertEqual(<<"2025-06-18">>, maps:get(<<"protocolVersion">>, Init2)), + ?assertMatch(#{<<"error">> := #{<<"code">> := -32602}}, rpc(Config, <<"initialize">>, #{})), + ?assertMatch(#{<<"result">> := #{}}, rpc(Config, <<"ping">>, #{})), + #{<<"result">> := #{<<"tools">> := Tools}} = rpc(Config, <<"tools/list">>, #{}), + Names = lists:sort([maps:get(<<"name">>, T) || T <- Tools]), + ?assertEqual(lists:sort(mcp_policy:default_tools()), Names), + ?assertEqual(13, length(Tools)), + [begin + ?assertMatch(#{<<"inputSchema">> := #{<<"type">> := <<"object">>, <<"additionalProperties">> := false}, + <<"outputSchema">> := #{<<"type">> := <<"object">>}, + <<"description">> := _, + <<"annotations">> := #{<<"readOnlyHint">> := true, <<"destructiveHint">> := false}}, T), + ?assert(byte_size(maps:get(<<"description">>, T)) > 40) + end || T <- Tools], + %% nothing dangerous is advertised or callable, and no prompts/resources exist + [?assertNotEqual(nomatch, re:run(N, "^[a-z_]+$")) || N <- Names], + [?assertEqual(nomatch, re:run(N, "eval|exec|rpc|shell|lookup|trace|kill|load|write|send", [{capture, none}])) + || N <- Names], + [?assertMatch(#{<<"error">> := #{<<"code">> := -32601}}, rpc(Config, M, #{})) + || M <- [<<"resources/list">>, <<"resources/read">>, <<"tools/eval">>, + <<"completion/complete">>, <<"logging/setLevel">>]], + [?assertMatch({rpc_error, -32602, _}, call(Config, T, #{})) + || T <- [<<"eval">>, <<"ets_lookup">>, <<"rpc_call">>, <<"sys_get_state">>, <<"shell">>, <<"trace">>, + <<"kill_process">>, <<"set_breakpoint">>, <<"load_code">>]]. + +initialize_tells_the_agent_which_tools_are_disabled(Config) -> + #{<<"result">> := #{<<"instructions">> := Instr}} = + rpc(Config, <<"initialize">>, #{<<"protocolVersion">> => <<"2025-06-18">>}), + %% allowlist_enforced config: only runtime_summary and ets_tables are enabled + ?assertNotEqual(nomatch, binary:match(Instr, <<"Not available in this session">>)), + ?assertNotEqual(nomatch, binary:match(Instr, <<"top_processes">>)), + ?assertNotEqual(nomatch, binary:match(Instr, <<"`default`">>)), + ?assertNotEqual(nomatch, binary:match(Instr, <<"Developer tools not enabled">>)), + %% enabled tools are not listed as disabled + ?assertEqual(nomatch, binary:match(Instr, <<"ets_tables,">>)), + ?assertEqual(nomatch, binary:match(Instr, <<", ets_tables">>)). + +initialize_is_silent_when_nothing_is_disabled(Config) -> + #{<<"result">> := #{<<"instructions">> := Instr}} = + rpc(Config, <<"initialize">>, #{<<"protocolVersion">> => <<"2025-06-18">>}), + ?assertEqual(nomatch, binary:match(Instr, <<"Not available in this session">>)), + %% only the developer tier is listed (it is off by default) + ?assertNotEqual(nomatch, binary:match(Instr, <<"Developer tools not enabled">>)). + +allowlist_enforced(Config) -> + #{<<"result">> := #{<<"tools">> := Tools}} = rpc(Config, <<"tools/list">>, #{}), + ?assertEqual([<<"ets_tables">>, <<"runtime_summary">>], lists:sort([maps:get(<<"name">>, T) || T <- Tools])), + ?assertMatch({ok, _}, call(Config, <<"runtime_summary">>, #{})), + [?assertMatch({rpc_error, -32602, _}, call(Config, T, #{})) + || T <- [<<"application_overview">>, <<"supervision_tree">>, <<"registered_processes">>, + <<"process_info">>, <<"debug_session">>]]. + +arguments_are_strict(Config) -> + [?assertMatch({rpc_error, -32602, _}, call(Config, T, A)) + || {T, A} <- [{<<"runtime_summary">>, #{<<"extra">> => 1}}, + {<<"runtime_summary">>, #{<<"redactNodeHost">> => <<"yes">>}}, + {<<"application_overview">>, #{<<"application">> => 5}}, + {<<"application_overview">>, #{<<"application">> => binary:copy(<<"a">>, 65)}}, + {<<"application_overview">>, #{<<"cursor">> => binary:copy(<<"a">>, 201)}}, + {<<"application_overview">>, #{<<"includeModules">> => 1}}, + {<<"supervision_tree">>, #{}}, + {<<"supervision_tree">>, #{<<"id">> => <<"x">>, <<"supervisor">> => <<"y">>}}, + {<<"supervision_tree">>, #{<<"id">> => <<"x">>, <<"maxDepth">> => 0}}, + {<<"supervision_tree">>, #{<<"id">> => <<"x">>, <<"maxDepth">> => 17}}, + {<<"process_info">>, #{}}, + {<<"process_info">>, #{<<"name">> => <<"a">>, <<"pid">> => <<"<0.1.0>">>}}, + {<<"process_info">>, #{<<"name">> => binary:copy(<<"a">>, 256)}}, + {<<"ets_tables">>, #{<<"table">> => <<"mcp_fx_orders">>}}, + {<<"debug_session">>, #{<<"anything">> => true}}]], + %% params must be an object + ?assertMatch(#{<<"error">> := #{<<"code">> := -32602}}, + rpc(Config, <<"tools/call">>, #{<<"name">> => <<"ets_tables">>, <<"arguments">> => [1]})). + +runtime_summary_redacts_node_host(Config) -> + S = ok_call(Config, <<"runtime_summary">>, #{}), + ?assertEqual(true, maps:get(<<"nodeRedacted">>, S)), + ?assertNotEqual(nomatch, binary:match(maps:get(<<"node">>, S), <<"@">>)), + Full = ok_call(Config, <<"runtime_summary">>, #{<<"redactNodeHost">> => false}), + ?assertEqual(atom_to_binary(node(), utf8), maps:get(<<"node">>, Full)), + ?assertEqual(list_to_binary(erlang:system_info(otp_release)), maps:get(<<"otpRelease">>, S)), + ?assertEqual(erlang:system_info(schedulers), maps:get(<<"schedulers">>, S)), + ?assert(maps:get(<<"processCount">>, S) > 10), + ?assert(maps:get(<<"uptimeMs">>, S) >= 0), + ?assert(maps:get(<<"runQueue">>, S) >= 0), + ?assertMatch(#{<<"count">> := N, <<"names">> := L} when is_integer(N) andalso is_list(L), maps:get(<<"connectedNodes">>, S)), + ?assertEqual(erlang:system_info(schedulers_online), maps:get(<<"schedulersOnline">>, S)), + #{<<"processes">> := #{<<"count">> := PC, <<"limit">> := PL, <<"usedPercent">> := PP}, + <<"atoms">> := #{<<"count">> := AC, <<"limit">> := AL}} = maps:get(<<"resources">>, S), + ?assert(PC > 10 andalso PL >= PC andalso PP >= 0 andalso PP < 100), + ?assert(AC > 0 andalso AL > AC), + ?assertMatch(#{<<"code">> := C} when is_integer(C), maps:get(<<"memory">>, S)), + ?assertMatch(#{<<"unit">> := <<"bytes">>, <<"total">> := T} when is_integer(T), maps:get(<<"memory">>, S)), + %% opaque, non-secret session id shared with the map contract + Sid = maps:get(<<"sessionId">>, S), + ?assertEqual(Sid, maps:get(<<"sessionId">>, ok_call(Config, <<"ets_tables">>, #{}))), + ?assertEqual(Sid, maps:get(sessionId, ?config(info, Config))). + +application_overview_discovers_fixture_without_names(Config) -> + [S] = all_pages(Config, <<"application_overview">>, #{}), + %% MAP-05 envelope + [?assert(maps:is_key(K, S)) || K <- [<<"schemaVersion">>, <<"sessionId">>, <<"collectionId">>, <<"startedAt">>, + <<"finishedAt">>, <<"scope">>, <<"entities">>, <<"relationships">>, + <<"complete">>, <<"truncated">>, <<"omissions">>, <<"nextCursor">>]], + ?assertEqual(<<"1.0">>, maps:get(<<"schemaVersion">>, S)), + ?assertEqual(null, maps:get(<<"nextCursor">>, S)), + ?assertEqual(true, maps:get(<<"complete">>, S)), + ?assertEqual(false, maps:get(<<"truncated">>, S)), + ?assert(maps:get(<<"startedAt">>, S) =< maps:get(<<"finishedAt">>, S)), + Ents = maps:get(<<"entities">>, S), + App = ent(<<"mcp_fixture_app">>, Ents), + ?assertEqual(<<"application">>, maps:get(<<"kind">>, App)), + ?assertEqual(<<"9.9.9">>, maps:get(<<"version">>, App)), + ?assertEqual([<<"kernel">>, <<"stdlib">>], lists:sort(maps:get(<<"dependencies">>, App))), + ?assertEqual(<<"discovered">>, maps:get(<<"rootStatus">>, App)), + [RootId] = maps:get(<<"roots">>, App), + Root = [E || E <- Ents, id_of(E) =:= RootId], + ?assertMatch([#{<<"name">> := <<"mcp_fx_root">>, <<"role">> := <<"supervisor">>, <<"kind">> := <<"process">>}], Root), + %% a library application has no start module: explained, not an error + ?assertEqual(<<"no_start_module">>, maps:get(<<"rootStatus">>, ent(<<"stdlib">>, Ents))), + ?assertEqual([], maps:get(<<"roots">>, ent(<<"stdlib">>, Ents))), + %% dependencies are declarations, with evidence and confidence + Deps = [R || #{<<"type">> := <<"depends_on">>, <<"from">> := F} = R <- maps:get(<<"relationships">>, S), + F =:= id_of(App)], + ?assertEqual(2, length(Deps)), + [?assertMatch(#{<<"confidence">> := <<"confirmed">>, <<"evidence">> := <<"declared", _/binary>>, + <<"observedAt">> := _}, D) || D <- Deps], + ?assertMatch(#{<<"type">> := <<"belongs_to">>, <<"from">> := RootId, <<"to">> := _}, + hd([R || #{<<"type">> := <<"belongs_to">>, <<"from">> := F} = R <- maps:get(<<"relationships">>, S), F =:= RootId])), + %% MAP-04: coverage limitations are explicit + ?assert(length(maps:get(<<"limitations">>, maps:get(<<"scope">>, S))) >= 3), + %% selection by discovered id; unknown ids are refused + Only = ok_call(Config, <<"application_overview">>, #{<<"application">> => id_of(App)}), + ?assertEqual(<<"discovered">>, maps:get(<<"rootStatus">>, ent(<<"mcp_fixture_app">>, maps:get(<<"entities">>, Only)))), + ?assertEqual([], by_name(<<"ssl">>, maps:get(<<"entities">>, Only))), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"unknown_or_expired_id">>}}}, + call(Config, <<"application_overview">>, #{<<"application">> => <<"a_deadbeef">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_id">>}}}, + call(Config, <<"application_overview">>, #{<<"application">> => RootId})), + %% modules: names and behaviours only when already loaded, never loaded by the inspector + M = ok_call(Config, <<"application_overview">>, #{<<"application">> => id_of(App), <<"includeModules">> => true}), + Mods = [E || #{<<"kind">> := <<"module">>} = E <- maps:get(<<"entities">>, M)], + ?assertEqual([<<"mcp_fixture_sup">>, <<"mcp_fixture_worker">>], lists:sort([maps:get(<<"name">>, E) || E <- Mods])), + ?assertEqual([<<"gen_server">>], maps:get(<<"behaviours">>, hd(by_name(<<"mcp_fixture_worker">>, Mods)))), + ?assert(lists:all(fun(E) -> is_boolean(maps:get(<<"loaded">>, E)) end, Mods)), + %% no compile path, no environment + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [S])), "SENTINEL|\\.beam|/ebin")). + +supervision_tree_matches_independent_expected_graph(Config) -> + RootId = fixture_root_id(Config), + Pages = all_pages(Config, <<"supervision_tree">>, #{<<"id">> => RootId}), + Expected = lists:sort([{<<"mcp_fx_root">>, <<"worker_a">>}, + {<<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>}, + {<<"mcp_fx_root">>, <<"mcp_fx_dyn_sup">>}, + {<<"mcp_fx_sub_sup">>, <<"worker_b">>}, + {<<"mcp_fx_sub_sup">>, <<"worker_c">>}]), + ?assertEqual(Expected, supervises_edges(Pages)), + Ents = entities(Pages), + ?assertEqual(<<"supervisor">>, maps:get(<<"role">>, ent(<<"mcp_fx_sub_sup">>, [E || E <- Ents, maps:get(<<"childId">>, E, undefined) =:= <<"mcp_fx_sub_sup">>]))), + Wa = ent(<<"mcp_fx_worker_a">>, Ents), + ?assertMatch(#{<<"role">> := <<"worker">>, <<"callbackModule">> := <<"mcp_fixture_worker">>, + <<"modules">> := [<<"mcp_fixture_worker">>], <<"behaviour">> := [<<"gen_server">>], + <<"restart">> := <<"permanent">>, <<"shutdown">> := 1000, <<"specAvailable">> := true}, Wa), + Wb = hd([E || #{<<"childId">> := <<"worker_b">>} = E <- Ents]), + ?assertMatch(#{<<"role">> := <<"worker">>, <<"restart">> := <<"transient">>, <<"name">> := null}, Wb), + %% typed edges carry evidence, observation time and confidence + [?assertMatch(#{<<"evidence">> := <<"supervisor:which_children/1">>, <<"confidence">> := <<"confirmed">>, + <<"observedAt">> := _}, R) + || #{<<"type">> := <<"supervises">>} = R <- relationships(Pages)], + %% never start arguments, dictionary, state + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [Pages])), "SENTINEL")), + ?assertNot(lists:any(fun(E) -> maps:is_key(<<"start">>, E) orelse maps:is_key(<<"args">>, E) end, Ents)), + %% no link/monitor edge in a supervision map, and complete + ?assertEqual([], [T || #{<<"type">> := T} <- relationships(Pages), + T =:= <<"linked_to">> orelse T =:= <<"monitors">>]), + [?assertEqual(true, maps:get(<<"complete">>, P)) || P <- Pages], + %% starting from an application id reaches the same tree; depth is bounded and explained + AppId = fixture_app_id(Config), + ?assertEqual(Expected, supervises_edges(all_pages(Config, <<"supervision_tree">>, #{<<"id">> => AppId}))), + Shallow = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId, <<"maxDepth">> => 1}), + ?assertEqual(lists:sort([{<<"mcp_fx_root">>, <<"worker_a">>}, {<<"mcp_fx_root">>, <<"mcp_fx_dyn_sup">>}, + {<<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>}]), supervises_edges([Shallow])), + ?assertEqual(false, maps:get(<<"complete">>, Shallow)), + ?assertEqual(true, maps:get(<<"truncated">>, Shallow)), + Om = [O || #{<<"reason">> := <<"limit_reached">>} = O <- maps:get(<<"omissions">>, Shallow)], + ?assertEqual(2, length(Om)), + %% progressive expansion: the omission's scope is a usable id + SubId = maps:get(<<"scope">>, hd(Om)), + Sub = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => SubId, <<"maxDepth">> => 1}), + ?assert(length(supervises_edges([Sub])) =:= 2 orelse length(supervises_edges([Sub])) =:= 0), + %% an explicitly supplied local supervisor + Named = ok_call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"mcp_fx_sub_sup">>}), + ?assertEqual([{<<"mcp_fx_sub_sup">>, <<"worker_b">>}, {<<"mcp_fx_sub_sup">>, <<"worker_c">>}], supervises_edges([Named])), + %% a plain worker is not treated as a supervisor (never called with supervisor APIs) + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_a_supervisor">>}}}, + call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"mcp_fx_worker_a">>})), + ?assert(is_process_alive(whereis(mcp_fx_worker_a))), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"no_such_registered_name_zz">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_a_supervisor">>}}}, + call(Config, <<"supervision_tree">>, #{<<"id">> => id_of(hd([E || #{<<"kind">> := <<"process">>, <<"role">> := <<"worker">>} = E <- Ents]))})). + +linked_unsupervised_process_is_not_supervised(Config) -> + Orphan = whereis(mcp_fx_orphan), + ?assert(is_pid(Orphan)), + Pages = all_pages(Config, <<"supervision_tree">>, #{<<"id">> => fixture_root_id(Config)}), + ?assertEqual([], by_name(<<"mcp_fx_orphan">>, entities(Pages))), + P = ok_call(Config, <<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_a">>}), + Ents = maps:get(<<"entities">>, P), + Wa = ent(<<"mcp_fx_worker_a">>, Ents), + Or = ent(<<"mcp_fx_orphan">>, Ents), + Links = [R || #{<<"type">> := <<"linked_to">>} = R <- maps:get(<<"relationships">>, P)], + ?assert(lists:any(fun(#{<<"from">> := F, <<"to">> := T}) -> F =:= id_of(Wa) andalso T =:= id_of(Or) end, Links)), + [?assertMatch(#{<<"confidence">> := <<"confirmed">>, <<"evidence">> := <<"process_info links">>}, L) || L <- Links], + %% a link is never a supervision edge + ?assertEqual([], [R || #{<<"type">> := <<"supervises">>} = R <- maps:get(<<"relationships">>, P)]). + +process_info_inputs_are_safe(Config) -> + P = ok_call(Config, <<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_c">>}), + Wc = ent(<<"mcp_fx_worker_c">>, maps:get(<<"entities">>, P)), + Allowed = [<<"id">>, <<"kind">>, <<"pid">>, <<"name">>, <<"alive">>, <<"role">>, <<"status">>, + <<"currentFunction">>, <<"initialCall">>, <<"reductions">>, <<"memory">>, <<"memoryUnit">>, + <<"messageQueueLen">>, <<"application">>, <<"membership">>, <<"callbackModule">>, <<"behaviour">>], + ?assertEqual([], maps:keys(Wc) -- Allowed), + ?assertMatch(#{<<"status">> := _, <<"reductions">> := R, <<"memory">> := M, <<"messageQueueLen">> := 0, + <<"memoryUnit">> := <<"bytes">>, <<"initialCall">> := _, <<"currentFunction">> := _} + when is_integer(R) andalso is_integer(M), Wc), + ?assertMatch(#{<<"confidence">> := _, <<"evidence">> := _}, maps:get(<<"membership">>, Wc)), + %% callback/behaviour are unknown unless safe metadata is available (no dictionary, no sys:get_state) + ?assertEqual(<<"unknown">>, maps:get(<<"callbackModule">>, Wc)), + %% same process by id and by pid text + Id = id_of(Wc), + ?assertEqual(Id, id_of(ent(<<"mcp_fx_worker_c">>, maps:get(<<"entities">>, ok_call(Config, <<"process_info">>, #{<<"id">> => Id}))))), + PidText = maps:get(<<"pid">>, Wc), + ?assertEqual(Id, id_of(ent(<<"mcp_fx_worker_c">>, maps:get(<<"entities">>, ok_call(Config, <<"process_info">>, #{<<"pid">> => PidText}))))), + %% hostile identifiers: nothing is created, nothing crosses the local node + Atoms = erlang:system_info(atom_count), + Hostile = [<<"<1.2.3>">>, <<"<0.99999999999.0>">>, <<"<0.1.0>x">>, <<"<0.1.0> ">>, <<"<0.0.0.0>">>, + <<"#Port<0.1>">>, <<"<0.1>">>, <<"">>], + [?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := C}}} when C =:= <<"invalid_pid">> orelse C =:= <<"not_found">>, + call(Config, <<"process_info">>, #{<<"pid">> => H})) || H <- Hostile], + [?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"process_info">>, #{<<"name">> => <<"unique_hostile_name_", (integer_to_binary(I))/binary>>})) + || I <- lists:seq(1, 50)], + ?assertEqual(Atoms, erlang:system_info(atom_count)), + %% an exited process is reported, not invented + Gone = spawn(fun() -> ok end), + timer:sleep(50), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"process_info">>, #{<<"pid">> => list_to_binary(pid_to_list(Gone))})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"unknown_or_expired_id">>}}}, + call(Config, <<"process_info">>, #{<<"id">> => <<"p_0000000000000000">>})), + %% an application id is not a process id + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_id">>}}}, + call(Config, <<"process_info">>, #{<<"id">> => fixture_app_id(Config)})). + +registered_processes_membership_evidence(Config) -> + AppId = fixture_app_id(Config), + Pages = all_pages(Config, <<"registered_processes">>, #{<<"application">> => AppId}), + Ents = entities(Pages), + Names = lists:sort([N || #{<<"name">> := N} <- Ents, is_binary(N)]), + ?assertEqual([<<"mcp_fx_dyn_sup">>, <<"mcp_fx_orphan">>, <<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>, + <<"mcp_fx_worker_a">>, <<"mcp_fx_worker_c">>], Names), + Conf = fun(N) -> maps:get(<<"confidence">>, maps:get(<<"membership">>, ent(N, Ents))) end, + %% found in the supervision tree: confirmed + [?assertEqual(<<"confirmed">>, Conf(N)) || N <- [<<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>, + <<"mcp_fx_worker_a">>, <<"mcp_fx_worker_c">>, <<"mcp_fx_dyn_sup">>]], + %% the unsupervised orphan shares the group leader only: never "confirmed" + ?assertEqual(<<"inferred">>, Conf(<<"mcp_fx_orphan">>)), + %% unrelated registered names are not silently assigned to the application + ?assertEqual([], by_name(<<"kernel_sup">>, Ents)), + ?assertEqual([], by_name(<<"mcp_fx_mailbox">>, Ents)), + ?assert(lists:any(fun(#{<<"reason">> := R}) -> R =:= <<"unknown_membership">> end, + lists:append([maps:get(<<"omissions">>, P) || P <- Pages]))), + [?assertEqual(false, maps:get(<<"complete">>, P)) || P <- Pages], + %% without a filter: bounded, membership stated per entry, coverage limitations explicit + All = all_pages(Config, <<"registered_processes">>, #{}), + AllEnts = entities(All), + ?assert(length(by_name(<<"kernel_sup">>, AllEnts)) =:= 1), + ?assertEqual(<<"unknown">>, maps:get(<<"confidence">>, maps:get(<<"membership">>, ent(<<"mcp_fx_mailbox">>, AllEnts)))), + %% inspector processes are never listed as application processes + ?assertEqual([], [N || #{<<"name">> := N} <- AllEnts, is_binary(N), binary:match(N, <<"mcp_server">>) =/= nomatch + orelse binary:match(N, <<"mcp_store">>) =/= nomatch orelse binary:match(N, <<"mcp_holder">>) =/= nomatch + orelse binary:match(N, <<"mcp_sup">>) =/= nomatch]), + ?assert(length(maps:get(<<"limitations">>, maps:get(<<"scope">>, hd(All)))) >= 3), + %% mailbox contents are never read: only the queue length + MB = ent(<<"mcp_fx_mailbox">>, AllEnts), + ?assertEqual(1, maps:get(<<"messageQueueLen">>, MB)), + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [All])), "SENTINEL")). + +ets_tables_metadata_only_for_approved_tables(Config) -> + S = ok_call(Config, <<"ets_tables">>, #{}), + Ents = maps:get(<<"entities">>, S), + Tabs = [E || #{<<"kind">> := <<"ets_table">>} = E <- Ents], + %% only the approved, non-private, existing table + ?assertMatch([#{<<"name">> := <<"mcp_fx_orders">>, <<"protection">> := <<"public">>, <<"type">> := <<"set">>, + <<"size">> := 1, <<"memoryUnit">> := <<"words">>}], Tabs), + [Tab] = Tabs, + ?assert(is_integer(maps:get(<<"memory">>, Tab))), + ?assertEqual(maps:get(<<"memory">>, Tab) * erlang:system_info(wordsize), maps:get(<<"memoryBytes">>, Tab)), + %% owner correlation: joinable with process entities via an owns_table edge + Owner = ent(<<"mcp_fx_worker_c">>, Ents), + ?assertEqual(id_of(Owner), maps:get(<<"owner">>, Tab)), + ?assertMatch([#{<<"from">> := _, <<"to">> := _, <<"evidence">> := <<"ets:info owner">>}], + [R || #{<<"type">> := <<"owns_table">>} = R <- maps:get(<<"relationships">>, S)]), + %% private and missing approved tables are counted without disclosing names + Reasons = lists:sort([{maps:get(<<"reason">>, O), maps:get(<<"count">>, O)} || O <- maps:get(<<"omissions">>, S)]), + ?assertEqual([{<<"disappeared">>, 1}, {<<"policy_denied">>, 1}], Reasons), + Text = iolist_to_binary(io_lib:format("~p", [maps:get(<<"omissions">>, S)])), + ?assertEqual(nomatch, binary:match(Text, <<"mcp_fx_private">>)), + ?assertEqual(nomatch, binary:match(Text, <<"zzz">>)), + %% no node-wide inventory: an unapproved table is invisible + ?assertEqual([], by_name(<<"ac_tab">>, Ents)), + %% keys/objects/values are never read + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [S])), "SENTINEL")), + ?assertEqual(false, maps:get(<<"complete">>, S)), + %% a recreated table has a different identity even with the same name + OldId = id_of(Tab), + ets:delete(mcp_fx_orders), + Owner2 = spawn(fun() -> ets:new(mcp_fx_orders, [named_table, public, set]), receive stop -> ok end end), + timer:sleep(100), + S2 = ok_call(Config, <<"ets_tables">>, #{}), + [Tab2] = [E || #{<<"kind">> := <<"ets_table">>} = E <- maps:get(<<"entities">>, S2)], + ?assertNotEqual(OldId, id_of(Tab2)), + ?assertEqual(0, maps:get(<<"size">>, Tab2)), + exit(Owner2, kill), + %% a disappeared table is reported, not invented + timer:sleep(50), + S3 = ok_call(Config, <<"ets_tables">>, #{}), + ?assertEqual([], [E || #{<<"kind">> := <<"ets_table">>} = E <- maps:get(<<"entities">>, S3)]), + ?assertEqual(2, lists:sum([maps:get(<<"count">>, O) || #{<<"reason">> := <<"disappeared">>} = O <- maps:get(<<"omissions">>, S3)])), + %% restore the fixture table (its owner is restarted by its supervisor) + OldOwner = whereis(mcp_fx_worker_c), + exit(OldOwner, kill), + wait_until(fun() -> is_pid(whereis(mcp_fx_worker_c)) andalso whereis(mcp_fx_worker_c) =/= OldOwner end), + wait_until(fun() -> ets:info(mcp_fx_orders, size) =:= 1 end). + +restart_changes_identity(Config) -> + RootId = fixture_root_id(Config), + Before = entities(all_pages(Config, <<"supervision_tree">>, #{<<"id">> => RootId})), + Old = hd([E || #{<<"childId">> := <<"worker_c">>} = E <- Before]), + OldPid = whereis(mcp_fx_worker_c), + exit(OldPid, kill), + wait_until(fun() -> is_pid(whereis(mcp_fx_worker_c)) andalso whereis(mcp_fx_worker_c) =/= OldPid end), + After = entities(all_pages(Config, <<"supervision_tree">>, #{<<"id">> => RootId})), + New = hd([E || #{<<"childId">> := <<"worker_c">>} = E <- After]), + ?assertNotEqual(id_of(Old), id_of(New)), + %% unchanged entities keep their id across collections (join across tools) + ?assertEqual(id_of(hd([E || #{<<"childId">> := <<"worker_a">>} = E <- Before])), + id_of(hd([E || #{<<"childId">> := <<"worker_a">>} = E <- After]))), + %% the previous identity is stale, never resolved to the new process + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"process_info">>, #{<<"id">> => id_of(Old)})), + %% absence of the old id from a later collection does not mean anything else was invented + ?assertEqual(supervises_edges([#{<<"entities">> => Before, <<"relationships">> => []}]), []), + %% a supervisor entry that is restarting/absent is preserved without a live pid: terminate_child keeps the spec + ok = supervisor:terminate_child(mcp_fx_sub_sup, worker_b), + Slots = entities(all_pages(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"mcp_fx_sub_sup">>})), + Wb = hd([E || #{<<"childId">> := <<"worker_b">>} = E <- Slots]), + ?assertMatch(#{<<"alive">> := false, <<"pid">> := null, <<"childState">> := <<"not_running">>}, Wb), + {ok, _} = supervisor:restart_child(mcp_fx_sub_sup, worker_b). + +pagination_cursors_and_retention(Config) -> + ok = mcp_fixture_sup:add_dynamic(30), + RootId = fixture_root_id(Config), + Args = #{<<"id">> => RootId}, + First = ok_call(Config, <<"supervision_tree">>, Args), + %% max_items counts entities and relationships together per page + ?assert(length(maps:get(<<"entities">>, First)) + length(maps:get(<<"relationships">>, First)) =< 10), + Cursor = maps:get(<<"nextCursor">>, First), + ?assert(is_binary(Cursor)), + Pages = collect_pages(Config, <<"supervision_tree">>, Args, First, [First]), + ?assert(length(Pages) > 5), + [?assert(length(maps:get(<<"entities">>, P)) + length(maps:get(<<"relationships">>, P)) =< 10) || P <- Pages], + %% every page belongs to the same retained collection: no restart, no duplicates + ?assertEqual(1, length(lists:usort([maps:get(<<"collectionId">>, P) || P <- Pages]))), + Ids = [id_of(E) || E <- entities(Pages)], + ?assertEqual(length(Ids), length(lists:usort(Ids))), + %% 1 root + 3 children + 2 (sub) + 30 dynamic workers, 35 supervises edges + ?assertEqual(36, length(Ids)), + ?assertEqual(35, length(supervises_edges(Pages))), + %% each page is bounded, the last has no cursor + ?assertEqual(null, maps:get(<<"nextCursor">>, lists:last(Pages))), + %% cross-page edges reference safe ids, flagged as outside the page + ?assert(lists:any(fun(#{<<"fromInPage">> := F, <<"toInPage">> := T}) -> not (F andalso T) end, relationships(Pages))), + %% cursors are bound to tool, arguments and session + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_cursor">>}}}, + call(Config, <<"supervision_tree">>, #{<<"id">> => fixture_app_id(Config), <<"cursor">> => Cursor})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_cursor">>}}}, + call(Config, <<"registered_processes">>, #{<<"cursor">> => Cursor})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_cursor">>}}}, + call(Config, <<"supervision_tree">>, Args#{<<"maxDepth">> => 3, <<"cursor">> => Cursor})), + [?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_cursor">>}}}, + call(Config, <<"supervision_tree">>, Args#{<<"cursor">> => Bad})) + || Bad <- [<<"c1.x.0.deadbeef">>, <<"garbage">>, <<>>, <<"c1..0.">>, <<"c1.c_0000.-1.aa">>, + flip_last(Cursor)]], + %% retention: the count budget evicts the oldest collection (max_collections = 2) + _ = ok_call(Config, <<"registered_processes">>, #{}), + _ = ok_call(Config, <<"ets_tables">>, #{}), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"cursor_expired">>}}}, + call(Config, <<"supervision_tree">>, Args#{<<"cursor">> => Cursor})), + %% retention: TTL (1.5 s) - an expired collection is never silently restarted + Fresh = ok_call(Config, <<"supervision_tree">>, Args), + Cursor2 = maps:get(<<"nextCursor">>, Fresh), + ?assertMatch({ok, _}, call(Config, <<"supervision_tree">>, Args#{<<"cursor">> => Cursor2})), + timer:sleep(1800), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"cursor_expired">>}}}, + call(Config, <<"supervision_tree">>, Args#{<<"cursor">> => Cursor2})), + %% bounded identity bookkeeping and retained state + Stats = mcp_store:stats(), + ?assert(maps:get(collections, Stats) =< 2), + wait_until(fun() -> maps:get(slots, mcp_store:stats()) =:= 0 end), + lists:foreach(fun({_, P, _, _}) -> supervisor:terminate_child(mcp_fx_dyn_sup, P) end, + supervisor:which_children(mcp_fx_dyn_sup)). + +collection_byte_budget_is_partial_and_non_resumable(Config) -> + ok = mcp_fixture_sup:add_dynamic(60), + Args = #{<<"supervisor">> => <<"mcp_fx_root">>}, + S = ok_call(Config, <<"supervision_tree">>, Args), + ?assertEqual(false, maps:get(<<"complete">>, S)), + ?assertEqual(true, maps:get(<<"truncated">>, S)), + [Om | _] = [O || #{<<"reason">> := <<"limit_reached">>, <<"resumable">> := false} = O <- maps:get(<<"omissions">>, S)], + ?assert(maps:get(<<"count">>, Om) > 0), + ?assertNotEqual(nomatch, binary:match(maps:get(<<"message">>, Om), <<"narrow">>)), + %% pages of the truncated collection still terminate and never claim completeness + Pages = collect_pages(Config, <<"supervision_tree">>, Args, S, [S]), + [?assertEqual(false, maps:get(<<"complete">>, P)) || P <- Pages], + ?assertEqual(null, maps:get(<<"nextCursor">>, lists:last(Pages))), + ?assert(maps:get(collection_bytes, mcp_store:stats()) =< 2 * 4096), + %% the whole response envelope stays under max_result_bytes + {200, _, Raw} = http(?config(port, Config), "POST", std_headers(Config), + iolist_to_binary(element(2, vscode_jsone:encode( + #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => 9, <<"method">> => <<"tools/call">>, + <<"params">> => #{<<"name">> => <<"supervision_tree">>, + <<"arguments">> => Args}})))), + ?assert(byte_size(Raw) =< 4096), + lists:foreach(fun({_, P, _, _}) -> supervisor:terminate_child(mcp_fx_dyn_sup, P) end, + supervisor:which_children(mcp_fx_dyn_sup)). + +blocked_supervisor_yields_partial_result(Config) -> + RootId = fixture_root_id(Config), + ok = sys:suspend(mcp_fx_sub_sup), + T0 = erlang:monotonic_time(millisecond), + S = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId}), + Elapsed = erlang:monotonic_time(millisecond) - T0, + ?assert(Elapsed < 3000), + ?assertEqual(false, maps:get(<<"complete">>, S)), + [Om] = [O || #{<<"reason">> := <<"timeout">>} = O <- maps:get(<<"omissions">>, S)], + %% no "paused" claim without debugger evidence + ?assertEqual([], [O || #{<<"reason">> := <<"unavailable_while_paused">>} = O <- maps:get(<<"omissions">>, S)]), + Ents = maps:get(<<"entities">>, S), + SubId = maps:get(<<"scope">>, Om), + ?assertEqual(<<"mcp_fx_sub_sup">>, maps:get(<<"childId">>, hd([E || E <- Ents, id_of(E) =:= SubId]))), + %% the healthy branches are still mapped, the blocked subtree is missing rather than invented + Edges = supervises_edges([S]), + ?assert(lists:member({<<"mcp_fx_root">>, <<"worker_a">>}, Edges)), + ?assertEqual([], [E || {<<"mcp_fx_sub_sup">>, _} = E <- Edges]), + %% the inspector resumed nothing and altered nothing + {status, _, _, [_, SysState | _]} = sys:get_status(mcp_fx_sub_sup), + ?assertEqual(suspended, SysState), + ok = sys:resume(mcp_fx_sub_sup), + %% inspector-owned workers and slots are back to baseline + wait_until(fun() -> maps:get(slots, mcp_store:stats()) =:= 0 end), + S2 = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId}), + ?assertEqual(true, maps:get(<<"complete">>, S2)), + ?assertEqual(5, length(supervises_edges([S2]))). + +concurrency_is_bounded(Config) -> + RootId = fixture_root_id(Config), + ok = sys:suspend(mcp_fx_sub_sup), + Self = self(), + spawn(fun() -> Self ! {first, call(Config, <<"supervision_tree">>, #{<<"id">> => RootId})} end), + wait_until(fun() -> maps:get(slots, mcp_store:stats()) =:= 1 end), + %% excess work is rejected with a clear MCP error, not queued + ?assertMatch({rpc_error, -32000, <<"inspector busy", _/binary>>}, call(Config, <<"runtime_summary">>, #{})), + receive {first, R} -> ?assertMatch({ok, _}, R) after 6000 -> ct:fail(no_first_result) end, + ok = sys:resume(mcp_fx_sub_sup), + wait_until(fun() -> maps:get(slots, mcp_store:stats()) =:= 0 end), + ?assertMatch({ok, _}, call(Config, <<"runtime_summary">>, #{})). + +stop_closes_endpoint_and_invalidates_credentials(Config) -> + Port = ?config(port, Config), + Tok = ?config(token, Config), + Cursor = maps:get(<<"nextCursor">>, ok_call_small(Config)), + ?assert(is_binary(Cursor)), + ?assertMatch({200, _, _}, http(Port, "POST", std_headers(Config), <<"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}">>)), + ok = mcp_sup:stop_session(), + ?assertNot(mcp_sup:active()), + ?assertEqual(undefined, whereis(mcp_server)), + ?assertEqual(undefined, whereis(mcp_store)), + ?assertEqual(undefined, whereis(mcp_sup)), + ?assertMatch({error, econnrefused}, gen_tcp:connect({127, 0, 0, 1}, Port, [binary], 1000)), + %% a new session has a fresh token and identity; old credentials and cursors are worthless + {ok, Info2} = mcp_sup:start_session(?config(cfg, Config), launch), + ?assertNotEqual(Tok, maps:get(token, Info2)), + ?assertNotEqual(maps:get(sessionId, ?config(info, Config)), maps:get(sessionId, Info2)), + Cfg2 = [{port, maps:get(port, Info2)}, {token, Tok}], + ?assertMatch({401, _, _}, http(maps:get(port, Info2), "POST", std_headers(Cfg2), <<"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}">>)), + New = [{port, maps:get(port, Info2)}, {token, maps:get(token, Info2)}], + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_cursor">>}}}, + call(New, <<"application_overview">>, #{<<"cursor">> => Cursor})), + %% concurrent start is refused: one inspector per node + ?assertMatch({error, <<"an MCP inspector is already running", _/binary>>}, mcp_sup:start_session(?config(cfg, Config), launch)), + ok = mcp_sup:stop_session(). + +ok_call_small(Config) -> + %% a call whose result spans several pages under the default limits: many registered names + Names = [list_to_atom("mcp_fx_pad_" ++ integer_to_list(I)) || I <- lists:seq(1, 600)], + Pids = [begin P = spawn(fun() -> receive stop -> ok end end), register(N, P), P end || N <- Names], + S = ok_call(Config, <<"registered_processes">>, #{}), + [exit(P, kill) || P <- Pids], + S. + +fixed_port_conflict_disables_without_rebinding(_Config) -> + {ok, Busy} = gen_tcp:listen(0, [binary, {ip, {127, 0, 0, 1}}, {reuseaddr, false}]), + {ok, Port} = inet:port(Busy), + Cfg = (mcp_policy:defaults())#{port => Port}, + {error, Msg} = mcp_sup:start_session(Cfg, launch), + ?assertNotEqual(nomatch, binary:match(Msg, <<"already in use">>)), + %% nothing partially initialized is left running, no other port was chosen + ?assertNot(mcp_sup:active()), + [?assertEqual(undefined, whereis(N)) || N <- [mcp_holder, mcp_sup, mcp_server, mcp_store]], + gen_tcp:close(Busy), + %% once the port is free the same fixed port can be used, and it is really that port + {ok, Info} = mcp_sup:start_session(Cfg, launch), + ?assertEqual(Port, maps:get(port, Info)), + ok = mcp_sup:stop_session(). + +renewal_keeps_the_session_alive_and_lease_expiry_stops_it(_Config) -> + {ok, Info} = mcp_sup:start_session(mcp_policy:defaults(), launch), + Port = maps:get(port, Info), + %% renewals (as the adapter sends them) keep the session beyond the lease duration + [begin timer:sleep(2000), ok = mcp_sup:renew() end || _ <- lists:seq(1, 6)], + ?assert(mcp_sup:active()), + ?assertMatch({ok, _}, gen_tcp:connect({127, 0, 0, 1}, Port, [binary], 1000)), + %% without renewal the inspector stops within the documented bound (10 s) + T0 = erlang:monotonic_time(millisecond), + wait_until(fun() -> not mcp_sup:active() end, 12000), + Elapsed = erlang:monotonic_time(millisecond) - T0, + ct:pal("lease expired after ~p ms without renewal", [Elapsed]), + ?assert(Elapsed =< 10000), + ?assertMatch({error, econnrefused}, gen_tcp:connect({127, 0, 0, 1}, Port, [binary], 1000)), + [?assertEqual(undefined, whereis(N)) || N <- [mcp_holder, mcp_sup, mcp_server, mcp_store]], + %% the application node itself is untouched + ?assert(is_pid(whereis(mcp_fx_root))). + +no_forbidden_data_no_atoms_no_stacktraces(Config) -> + %% warm up runtime dependencies (and the atoms they need), then measure + RootId = fixture_root_id(Config), + AppId = fixture_app_id(Config), + Calls = [{<<"runtime_summary">>, #{}}, {<<"debug_session">>, #{}}, + {<<"application_overview">>, #{<<"includeModules">> => true}}, + {<<"supervision_tree">>, #{<<"id">> => RootId, <<"includeModules">> => true}}, + {<<"registered_processes">>, #{}}, {<<"registered_processes">>, #{<<"application">> => AppId}}, + {<<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_a">>}}, + {<<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_c">>}}, + {<<"ets_tables">>, #{}}], + Results = [call(Config, T, A) || {T, A} <- Calls], + Atoms = erlang:system_info(atom_count), + %% hostile input, repeated with unique strings + Hostile = lists:flatten( + [[call(Config, <<"process_info">>, #{<<"name">> => <<"hostile_", (integer_to_binary(I))/binary>>}), + call(Config, <<"process_info">>, #{<<"pid">> => <<"<0.", (integer_to_binary(I + 100000))/binary, ".0>">>}), + call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"sup_", (integer_to_binary(I))/binary>>}), + call(Config, <<"application_overview">>, #{<<"application">> => <<"a_", (integer_to_binary(I))/binary>>}), + call(Config, list_to_binary("no_such_tool_" ++ integer_to_list(I)), #{})] + || I <- lists:seq(1, 60)]), + _ = [http(?config(port, Config), "POST", std_headers(Config), <<"{\"a\":", (integer_to_binary(I))/binary>>) || I <- lists:seq(1, 20)], + _ = [http(?config(port, Config), "POST", [{"Host", ["h", integer_to_list(I)]}], <<>>) || I <- lists:seq(1, 20)], + %% >340 unique hostile inputs: allow only unrelated background noise + ?assert(erlang:system_info(atom_count) - Atoms < 20), + Text = iolist_to_binary(io_lib:format("~p", [Results ++ Hostile])), + %% forbidden data (state, dictionary, mailbox, ETS values, start args, env) is never returned + ?assertEqual(nomatch, binary:match(Text, <<"SENTINEL">>)), + %% no stack traces, paths or internal terms in errors + [?assertEqual(nomatch, re:run(Text, P, [{capture, none}])) + || P <- ["badmatch", "function_clause", "case_clause", "\\.erl", "/Users/", "/home/", "stacktrace"]], + %% every advertised tool answered with a well-formed result + [?assertMatch({ok, _}, R) || R <- Results]. + +logs_never_contain_token_or_runtime_data(Config) -> + Self = self(), + HandlerId = mcp_capture_handler, + ok = logger:add_handler(HandlerId, mcp_capture_logger, #{config => #{owner => Self}, level => all}), + PrevLevel = maps:get(level, logger:get_primary_config()), + ok = logger:set_primary_config(level, all), + try + Tok = ?config(token, Config), + _ = call(Config, <<"runtime_summary">>, #{}), + _ = call(Config, <<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_a">>}), + _ = call(Config, <<"application_overview">>, #{}), + _ = http(?config(port, Config), "POST", [{"Host", ["127.0.0.1:", integer_to_list(?config(port, Config))]}, + {"Authorization", "Bearer wrongtoken_SENTINEL_BAD"}], <<"{}">>), + _ = http(?config(port, Config), "POST", [{"Host", "evil"}], <<"{}">>), + timer:sleep(200), + Logged = collect_logs([]), + ct:pal("captured ~p log events", [length(Logged)]), + Text = iolist_to_binary(io_lib:format("~p", [Logged])), + ?assertNotEqual([], Logged), + ?assertEqual(nomatch, binary:match(Text, Tok)), + ?assertEqual(nomatch, binary:match(Text, <<"wrongtoken">>)), + ?assertEqual(nomatch, binary:match(Text, <<"SENTINEL">>)), + ?assertEqual(nomatch, binary:match(Text, <<"mcp_fx_worker_a">>)), + ?assertNotEqual(nomatch, binary:match(Text, <<"tool_call">>)) + after + logger:remove_handler(HandlerId), + logger:set_primary_config(level, PrevLevel) + end. + +collect_logs(Acc) -> + receive {mcp_log, E} -> collect_logs([E | Acc]) after 100 -> lists:reverse(Acc) end. + +lsp_application_never_starts_the_inspector(_Config) -> + %% enabling nothing: the LSP application (and its settings) never start MCP + ?assertNot(mcp_sup:active()), + ok = application:start(vscode_lsp, permanent), + try + ?assertNot(mcp_sup:active()), + [?assertEqual(undefined, whereis(N)) || N <- [mcp_holder, mcp_sup, mcp_server, mcp_store]], + %% the LSP application supervisor knows no MCP child + Kids = supervisor:which_children(vscode_lsp_app_sup), + ?assertEqual([], [K || {Id, _, _, _} = K <- Kids, lists:prefix("mcp_", lists:flatten(io_lib:format("~w", [Id])))]), + %% the entry module's hot-loaded module list does not include MCP + {ok, Beam} = file:read_file(code:where_is_file("vscode_lsp_entry.beam")), + ?assertEqual(nomatch, binary:match(Beam, <<"mcp_sup">>)) + after + application:stop(vscode_lsp) + end. + +flip_last(Bin) -> + Size = byte_size(Bin) - 1, + <> = Bin, + New = case Last of $a -> $b; _ -> $a end, + <>. + +journal_reports_request_metadata_only(Config) -> + Port = ?config(port, Config), + Tok = ?config(token, Config), + #{<<"result">> := _} = rpc(Config, <<"initialize">>, #{<<"protocolVersion">> => <<"2025-06-18">>}), + #{<<"result">> := _} = rpc(Config, <<"tools/list">>, #{}), + ok = mcp_fixture_sup:add_dynamic(5), + First = ok_call(Config, <<"registered_processes">>, #{}), + _ = call(Config, <<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_a">>}), + _ = call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"SENTINEL_no_such_sup">>}), + _ = call(Config, <<"SENTINEL_tool">>, #{}), + {401, _, _} = http(Port, "POST", [{"Host", ["127.0.0.1:", integer_to_list(Port)]}, + {"Content-Type", "application/json"}], <<"{}">>), + Entries = lists:sort(fun(A, B) -> maps:get(seq, A) =< maps:get(seq, B) end, collect_journal(7)), + ?assertEqual(lists:seq(1, 7), [maps:get(seq, E) || E <- Entries]), + [Init, List, Reg, Pinfo, Tree, Unknown, Http] = Entries, + ?assertMatch(#{method := <<"initialize">>, status := <<"ok">>, durationMs := _, bytes := _}, Init), + ?assertMatch(#{method := <<"tools/list">>, status := <<"ok">>}, List), + ?assertMatch(#{method := <<"tools/call">>, tool := <<"registered_processes">>, status := <<"ok">>, + entities := N, relationships := 0, offset := 0, cursor := false, complete := true} + when N > 0, Reg), + ?assertEqual(maps:get(<<"nextCursor">>, First) =/= null, maps:get(more, Reg)), + ?assertMatch(#{tool := <<"process_info">>, status := <<"ok">>, entities := _}, Pinfo), + ?assertMatch(#{tool := <<"supervision_tree">>, status := <<"not_found">>}, Tree), + %% unknown tool names never reach the journal as text + ?assertMatch(#{tool := <<"?">>, status := <<"-32602">>}, Unknown), + ?assertMatch(#{method := <<"http">>, status := <<"401">>}, Http), + %% metadata only: no argument, result content or credential + Text = iolist_to_binary(io_lib:format("~p", [Entries])), + [?assertEqual(nomatch, binary:match(Text, Forbidden)) + || Forbidden <- [Tok, <<"SENTINEL">>, <<"mcp_fx_worker_a">>, <<"kernel_sup">>]], + Allowed = [seq, method, tool, cursor, status, durationMs, bytes, entities, relationships, + omissions, complete, more, offset], + ?assertEqual([], lists:usort(lists:append([maps:keys(E) || E <- Entries])) -- Allowed), + ?assertEqual(7, maps:get(seq, mcp_store:stats())), + lists:foreach(fun({_, P, _, _}) -> supervisor:terminate_child(mcp_fx_dyn_sup, P) end, + supervisor:which_children(mcp_fx_dyn_sup)). + +agent_summary_is_compact(Config) -> + Summary = ok_call(Config, <<"application_overview">>, #{<<"detail">> => <<"summary">>}), + Full = ok_call(Config, <<"application_overview">>, #{<<"detail">> => <<"full">>}), + ?assertEqual(<<"summary">>, maps:get(<<"detail">>, Summary)), + ?assertEqual(<<"full">>, maps:get(<<"detail">>, Full)), + %% same graph (ids, kinds, edges), fewer fields + Ids = fun(S) -> lists:sort([maps:get(<<"id">>, E) || E <- maps:get(<<"entities">>, S)]) end, + ?assertEqual(Ids(Full), Ids(Summary)), + ?assertEqual(length(maps:get(<<"relationships">>, Full)), length(maps:get(<<"relationships">>, Summary))), + {ok, J1} = vscode_jsone:encode(Summary), + {ok, J2} = vscode_jsone:encode(Full), + ?assert(iolist_size(J1) < iolist_size(J2)), + App = ent(<<"mcp_fixture_app">>, maps:get(<<"entities">>, Summary)), + ?assertNot(maps:is_key(<<"description">>, App)), + ?assertMatch(#{<<"roots">> := [_], <<"version">> := <<"9.9.9">>, <<"rootStatus">> := <<"discovered">>}, App), + [?assertNot(maps:is_key(<<"observedAt">>, R)) || R <- maps:get(<<"relationships">>, Summary)], + [?assertMatch(#{<<"evidence">> := _, <<"confidence">> := _}, R) || R <- maps:get(<<"relationships">>, Summary)], + %% node-wide module lists are refused in summary (explained), allowed per application + NoMods = ok_call(Config, <<"application_overview">>, #{<<"detail">> => <<"summary">>, <<"includeModules">> => true}), + ?assertEqual([], [E || #{<<"kind">> := <<"module">>} = E <- maps:get(<<"entities">>, NoMods)]), + ?assertMatch([#{<<"reason">> := <<"limit_reached">>}], maps:get(<<"omissions">>, NoMods)), + Mods = ok_call(Config, <<"application_overview">>, #{<<"detail">> => <<"summary">>, <<"includeModules">> => true, + <<"application">> => maps:get(<<"id">>, App)}), + ?assertEqual(2, length([E || #{<<"kind">> := <<"module">>} = E <- maps:get(<<"entities">>, Mods)])), + %% agent pages: 100 items by default, pageSize honoured, limitations only on the first page + ok = mcp_fixture_sup:add_dynamic(120), + Args = #{<<"supervisor">> => <<"mcp_fx_root">>, <<"detail">> => <<"summary">>}, + P1 = ok_call(Config, <<"supervision_tree">>, Args), + ?assertEqual(100, length(maps:get(<<"entities">>, P1)) + length(maps:get(<<"relationships">>, P1))), + ?assert(maps:is_key(<<"limitations">>, maps:get(<<"scope">>, P1))), + P2 = ok_call(Config, <<"supervision_tree">>, Args#{<<"cursor">> => maps:get(<<"nextCursor">>, P1)}), + ?assertNot(maps:is_key(<<"limitations">>, maps:get(<<"scope">>, P2))), + Small = ok_call(Config, <<"supervision_tree">>, Args#{<<"pageSize">> => 7}), + ?assertEqual(7, length(maps:get(<<"entities">>, Small)) + length(maps:get(<<"relationships">>, Small))), + Big = ok_call(Config, <<"supervision_tree">>, #{<<"supervisor">> => <<"mcp_fx_root">>, <<"detail">> => <<"full">>}), + ?assert(length(maps:get(<<"entities">>, Big)) + length(maps:get(<<"relationships">>, Big)) > 100), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"supervision_tree">>, Args#{<<"detail">> => <<"verbose">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"supervision_tree">>, Args#{<<"pageSize">> => 0})), + lists:foreach(fun({_, P, _, _}) -> supervisor:terminate_child(mcp_fx_dyn_sup, P) end, + supervisor:which_children(mcp_fx_dyn_sup)). + +top_processes_ranks_and_is_bounded(Config) -> + Hot = spawn(fun() -> register(mcp_fx_hot, self()), receive never -> ok end end), + wait_until(fun() -> whereis(mcp_fx_hot) =:= Hot end), + [Hot ! {msg, I} || I <- lists:seq(1, 600)], + S = ok_call(Config, <<"top_processes">>, #{<<"limit">> => 3}), + Ents = maps:get(<<"entities">>, S), + ?assertEqual(3, length(Ents)), + ?assertEqual([1, 2, 3], [maps:get(<<"rank">>, E) || E <- Ents]), + [First | _] = Ents, + ?assertMatch(#{<<"name">> := <<"mcp_fx_hot">>, <<"messageQueueLen">> := Q, <<"rankedBy">> := <<"message_queue_len">>} + when Q >= 600, First), + Qs = [maps:get(<<"messageQueueLen">>, E) || E <- Ents], + ?assertEqual(lists:reverse(lists:sort(Qs)), Qs), + ?assertMatch(#{<<"sortBy">> := <<"message_queue_len">>, <<"limit">> := 3, <<"processCount">> := _}, + maps:get(<<"scope">>, S)), + %% metadata only: no mailbox content, no relationships, no inspector processes + ?assertEqual([], maps:get(<<"relationships">>, S)), + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [S])), "SENTINEL|\\{msg")), + [?assertEqual(false, lists:member(maps:get(<<"name">>, E), [<<"mcp_server">>, <<"mcp_store">>, <<"mcp_sup">>])) + || E <- Ents], + %% the other criteria rank by their own value + lists:foreach(fun({By, Field}) -> + R = ok_call(Config, <<"top_processes">>, #{<<"sortBy">> => By, <<"limit">> => 5}), + Es = maps:get(<<"entities">>, R), + ?assertEqual(5, length(Es)), + Vs = [maps:get(Field, E) || E <- Es], + ?assertEqual(lists:reverse(lists:sort(Vs)), Vs), + [?assertEqual(By, maps:get(<<"rankedBy">>, E)) || E <- Es] + end, [{<<"reductions">>, <<"reductions">>}, {<<"memory">>, <<"memory">>}]), + %% a default call is bounded to 10 and the ids join with other tools + D = ok_call(Config, <<"top_processes">>, #{}), + ?assertEqual(10, length(maps:get(<<"entities">>, D))), + HotId = id_of(ent(<<"mcp_fx_hot">>, maps:get(<<"entities">>, D))), + ?assertEqual(HotId, id_of(ent(<<"mcp_fx_hot">>, maps:get(<<"entities">>, + ok_call(Config, <<"process_info">>, #{<<"id">> => HotId}))))), + %% strict arguments + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_processes">>, #{<<"limit">> => 51})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_processes">>, #{<<"limit">> => 0})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_processes">>, #{<<"sortBy">> => <<"links">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_processes">>, #{<<"mailbox">> => true})), + exit(Hot, kill). + +supervision_tree_reports_child_counts(Config) -> + Pages = all_pages(Config, <<"supervision_tree">>, #{<<"id">> => fixture_root_id(Config)}), + Ents = entities(Pages), + Sub = hd([E || #{<<"childId">> := <<"mcp_fx_sub_sup">>} = E <- Ents]), + ?assertMatch(#{<<"children">> := #{<<"specs">> := 2, <<"active">> := 2, + <<"supervisors">> := 0, <<"workers">> := 2}}, Sub), + Root = hd([E || #{<<"name">> := <<"mcp_fx_root">>} = E <- Ents]), + ?assertMatch(#{<<"children">> := #{<<"specs">> := 3, <<"active">> := 3, + <<"supervisors">> := 2, <<"workers">> := 1}}, Root), + %% workers carry no counts + ?assertEqual([], [E || #{<<"role">> := <<"worker">>} = E <- Ents, maps:is_key(<<"children">>, E)]). + +topology_overview_maps_one_application_in_one_call(Config) -> + Pages = all_pages(Config, <<"topology_overview">>, #{<<"name">> => <<"mcp_fixture_app">>}), + Ents = entities(Pages), + %% the application, its tree (same edges as supervision_tree), registered workers and the owned table + ?assertMatch(#{<<"kind">> := <<"application">>, <<"version">> := <<"9.9.9">>}, ent(<<"mcp_fixture_app">>, Ents)), + ?assertEqual(lists:sort([{<<"mcp_fx_root">>, <<"worker_a">>}, {<<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>}, + {<<"mcp_fx_root">>, <<"mcp_fx_dyn_sup">>}, {<<"mcp_fx_sub_sup">>, <<"worker_b">>}, + {<<"mcp_fx_sub_sup">>, <<"worker_c">>}]), supervises_edges(Pages)), + Sub = hd([E || #{<<"childId">> := <<"mcp_fx_sub_sup">>} = E <- Ents]), + ?assertMatch(#{<<"children">> := #{<<"specs">> := 2}}, Sub), + ?assertMatch(#{<<"kind">> := <<"ets_table">>, <<"protection">> := <<"public">>}, ent(<<"mcp_fx_orders">>, Ents)), + %% a private or unrelated table is not in the application's map + ?assertEqual([], by_name(<<"mcp_fx_private">>, Ents)), + Owns = [R || #{<<"type">> := <<"owns_table">>} = R <- relationships(Pages)], + ?assertEqual(1, length(Owns)), + ?assertEqual(<<"belongs_to">>, maps:get(<<"type">>, hd([R || #{<<"type">> := <<"belongs_to">>} = R <- relationships(Pages)]))), + %% the orphan (linked, unsupervised, registered) is at most an inferred member, never supervised + ?assertMatch(#{<<"membership">> := #{<<"confidence">> := <<"inferred">>}}, ent(<<"mcp_fx_orphan">>, Ents)), + OrphanId = id_of(ent(<<"mcp_fx_orphan">>, Ents)), + ?assertEqual([], [R || #{<<"type">> := <<"supervises">>, <<"to">> := T} = R <- relationships(Pages), T =:= OrphanId]), + %% unrelated registered processes are reported as omitted, never merged in + Reasons = lists:usort([maps:get(<<"reason">>, O) || P <- Pages, O <- maps:get(<<"omissions">>, P)]), + ?assertEqual([], Reasons -- [<<"unknown_membership">>]), + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [Pages])), "SENTINEL")), + ?assertMatch(#{<<"tool">> := <<"topology_overview">>, <<"parts">> := [_, _, _, _]}, maps:get(<<"scope">>, hd(Pages))), + %% by id gives the same collection content; exactly one selector, strict arguments + AppId = fixture_app_id(Config), + ?assertEqual(supervises_edges(Pages), supervises_edges(all_pages(Config, <<"topology_overview">>, #{<<"application">> => AppId}))), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"topology_overview">>, #{})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"topology_overview">>, #{<<"name">> => <<"a">>, <<"application">> => AppId})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"topology_overview">>, #{<<"name">> => <<"no_such_application_zz">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"topology_overview">>, #{<<"name">> => <<"kernel_not_started_zz">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"invalid_id">>}}}, + call(Config, <<"topology_overview">>, #{<<"application">> => id_of(hd([E || #{<<"role">> := <<"supervisor">>} = E <- Ents]))})). + +topology_overview_leaves_out_parts_the_policy_denies(Config) -> + S = ok_call(Config, <<"topology_overview">>, #{<<"name">> => <<"mcp_fixture_app">>}), + ?assertMatch(#{<<"parts">> := [<<"supervision_tree">>, <<"application_overview">>]}, maps:get(<<"scope">>, S)), + Denied = [maps:get(<<"message">>, O) || #{<<"reason">> := <<"policy_denied">>} = O <- maps:get(<<"omissions">>, S)], + ?assertEqual(2, length(Denied)), + ?assertEqual([], [E || #{<<"kind">> := <<"ets_table">>} = E <- maps:get(<<"entities">>, S)]), + ?assertEqual(false, maps:get(<<"complete">>, S)). + +changes_since_reports_replaced_and_removed_children(Config) -> + ok = mcp_fixture_sup:add_dynamic(2), + RootId = fixture_root_id(Config), + Args = #{<<"id">> => RootId}, + Base = ok_call(Config, <<"supervision_tree">>, Args), + BaseId = maps:get(<<"collectionId">>, Base), + BaseEnts = entities([Base]), + OldB = hd([E || #{<<"childId">> := <<"worker_b">>} = E <- BaseEnts]), + %% nothing happened: no change, and the diff itself is complete + Same = ok_call(Config, <<"changes_since">>, #{<<"collectionId">> => BaseId}), + ?assertMatch(#{<<"summary">> := #{<<"added">> := 0, <<"removed">> := 0, <<"replaced">> := 0}}, maps:get(<<"scope">>, Same)), + ?assertEqual([], maps:get(<<"entities">>, Same)), + %% a restarted worker is `replaced` (new id), a terminated dynamic worker is `removed` + exit(hd([P || {worker_b, P, _, _} <- supervisor:which_children(mcp_fx_sub_sup), is_pid(P)]), kill), + wait_until(fun() -> [P || {worker_b, P, _, _} <- supervisor:which_children(mcp_fx_sub_sup), is_pid(P)] =/= [] end), + [{_, Dyn, _, _} | _] = supervisor:which_children(mcp_fx_dyn_sup), + ok = supervisor:terminate_child(mcp_fx_dyn_sup, Dyn), + D = ok_call(Config, <<"changes_since">>, #{<<"collectionId">> => BaseId}), + ?assertMatch(#{<<"summary">> := #{<<"replaced">> := 1, <<"removed">> := 1, <<"added">> := 0}}, maps:get(<<"scope">>, D)), + Ents = maps:get(<<"entities">>, D), + [Replaced] = [E || #{<<"change">> := <<"replaced">>} = E <- Ents], + ?assertMatch(#{<<"childId">> := <<"worker_b">>, <<"previousId">> := _}, Replaced), + ?assertEqual(id_of(OldB), maps:get(<<"previousId">>, Replaced)), + ?assertNotEqual(id_of(OldB), id_of(Replaced)), + [Removed] = [E || #{<<"change">> := <<"removed">>} = E <- Ents], + ?assertMatch(#{<<"absence">> := <<"observed">>}, Removed), + %% the edge that leads to the replacement is included; unchanged children are not listed + ?assertEqual([<<"worker_b">>], [maps:get(<<"childId">>, E) || #{<<"childId">> := C} = E <- Ents, C =:= <<"worker_b">>]), + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [D])), "SENTINEL")), + %% unknown baselines, strict arguments, and a changes_since collection is not a baseline + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"baseline_expired">>}}}, + call(Config, <<"changes_since">>, #{<<"collectionId">> => <<"c_000000000000">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"changes_since">>, #{})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := C}}} when C =:= <<"invalid_baseline">> orelse C =:= <<"baseline_expired">>, + call(Config, <<"changes_since">>, #{<<"collectionId">> => maps:get(<<"collectionId">>, D)})), + lists:foreach(fun({_, P, _, _}) -> supervisor:terminate_child(mcp_fx_dyn_sup, P) end, + supervisor:which_children(mcp_fx_dyn_sup)). + +top_ports_reports_drivers_and_owners_only(Config) -> + {ok, L} = gen_tcp:listen(0, [binary, {active, false}, {ip, {127, 0, 0, 1}}]), + {ok, LPort} = inet:port(L), + {ok, C} = gen_tcp:connect({127, 0, 0, 1}, LPort, [binary, {active, false}]), + {ok, A} = gen_tcp:accept(L, 2000), + ok = gen_tcp:send(C, binary:copy(<<"SENTINEL_PORT_DATA">>, 2000)), + Cat = open_port({spawn_executable, os:find_executable("cat")}, [binary]), + try + S = ok_call(Config, <<"top_ports">>, #{<<"sortBy">> => <<"output">>, <<"limit">> => 50}), + Ents = maps:get(<<"entities">>, S), + Ports = [E || #{<<"role">> := <<"port">>} = E <- Ents], + ?assert(length(Ports) >= 3), + ?assertEqual(lists:seq(1, length(Ports)), [maps:get(<<"rank">>, E) || E <- Ports]), + Outs = [maps:get(<<"output">>, E) || E <- Ports], + ?assertEqual(lists:reverse(lists:sort(Outs)), Outs), + Drivers = [maps:get(<<"driver">>, E) || E <- Ports], + ?assert(lists:member(<<"tcp_inet">>, Drivers)), + %% a spawned program is never named: no command line or path in the result + ?assert(lists:member(<<"">>, Drivers)), + Dump = iolist_to_binary(io_lib:format("~p", [S])), + [?assertEqual(nomatch, binary:match(Dump, X)) + || X <- [list_to_binary(os:find_executable("cat")), <<"SENTINEL">>, <<"127.0.0.1">>]], + %% owner edge: the test process owns the sockets and the program port + Owns = [R || #{<<"type">> := <<"owns_port">>} = R <- maps:get(<<"relationships">>, S)], + ?assert(length(Owns) >= 3), + [?assertMatch(#{<<"evidence">> := <<"port_info connected">>, <<"confidence">> := <<"confirmed">>}, R) || R <- Owns], + Allowed = [<<"id">>, <<"kind">>, <<"role">>, <<"pid">>, <<"name">>, <<"alive">>, <<"rank">>, <<"rankedBy">>, + <<"driver">>, <<"input">>, <<"output">>, <<"queueSize">>, <<"bytesUnit">>], + [?assertEqual([], maps:keys(E) -- Allowed) || E <- Ports], + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_ports">>, #{<<"sortBy">> => <<"peer">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"top_ports">>, #{<<"limit">> => 99})) + after + catch port_close(Cat), + [catch gen_tcp:close(X) || X <- [A, C, L]] + end. + +%% anonymous workers of one spawn site (the process_groups test) +idle() -> receive stop -> ok end. + +process_groups_aggregates_anonymous_processes_in_a_fixed_size_answer(Config) -> + Pids = [spawn(?MODULE, idle, []) || _ <- lists:seq(1, 400)], + try + S = ok_call(Config, <<"process_groups">>, #{<<"limit">> => 5}), + Groups = maps:get(<<"groups">>, S), + ?assertEqual(5, length(Groups)), + [Top | _] = Groups, + %% the 400 anonymous workers are one group, not 400 entries + ?assertMatch(#{<<"rank">> := 1, <<"initialCall">> := <<"mcp_server_SUITE:idle/0">>, + <<"module">> := <<"mcp_server_SUITE">>, <<"count">> := C, + <<"registered">> := 0, <<"unregistered">> := C} when C >= 400, Top), + ?assertEqual(3, length(maps:get(<<"samples">>, Top))), + %% the sample ids join with process_info + [Id | _] = maps:get(<<"samples">>, Top), + ?assertMatch(#{<<"entities">> := [_ | _]}, ok_call(Config, <<"process_info">>, #{<<"id">> => Id})), + Counts = [maps:get(<<"count">>, G) || G <- Groups], + ?assertEqual(lists:reverse(lists:sort(Counts)), Counts), + #{<<"processCount">> := PC, <<"scanned">> := Sc, <<"registered">> := Reg, <<"unregistered">> := Un, + <<"shownCoverPercent">> := Cover} = Totals = maps:get(<<"totals">>, S), + ?assert(PC >= 400 andalso Sc >= 400 andalso Reg + Un =:= Sc), + ?assert(Cover > 0 andalso Cover =< 100), + ?assertMatch(#{<<"groups">> := G, <<"groupsShown">> := 5} when G >= 5, Totals), + ?assertEqual(true, maps:get(<<"complete">>, S)), + %% OTP processes are grouped by their callback module; inspector processes are not listed + All = maps:get(<<"groups">>, ok_call(Config, <<"process_groups">>, #{<<"limit">> => 50})), + Calls = [maps:get(<<"initialCall">>, G) || G <- All], + [?assertEqual(nomatch, binary:match(C1, X)) || C1 <- Calls, X <- [<<"mcp_runtime:">>, <<"mcp_server:">>, <<"mcp_store:">>]], + ?assert(length(All) =< 50), + %% other criteria, and strict arguments + lists:foreach(fun({By, Field}) -> + R = ok_call(Config, <<"process_groups">>, #{<<"sortBy">> => By, <<"limit">> => 8}), + Vs = [maps:get(Field, G) || G <- maps:get(<<"groups">>, R)], + ?assertEqual(lists:reverse(lists:sort(Vs)), Vs) + end, [{<<"memory">>, <<"memoryBytes">>}, {<<"reductions">>, <<"reductions">>}, + {<<"message_queue_len">>, <<"messageQueueLen">>}]), + ?assertEqual(nomatch, re:run(iolist_to_binary(io_lib:format("~p", [S])), "SENTINEL")), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_groups">>, #{<<"limit">> => 51})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_groups">>, #{<<"sortBy">> => <<"links">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_groups">>, #{<<"cursor">> => <<"x">>})) + after + [exit(P, kill) || P <- Pids] + end. + +ets_summary_aggregates_per_owner_without_names(Config) -> + Self = self(), + Big = spawn(fun() -> + ets:new(mcp_fx_hidden_big, [named_table, public]), + [ets:insert(mcp_fx_hidden_big, {I, "SENTINEL_BIG_VALUE", lists:seq(1, 20)}) || I <- lists:seq(1, 5000)], + Self ! ready, + receive stop -> ok end + end), + receive ready -> ok after 5000 -> ct:fail(no_big_table) end, + try + S = ok_call(Config, <<"ets_summary">>, #{<<"limit">> => 5}), + Ents = maps:get(<<"entities">>, S), + [First | _] = Ents, + ?assertEqual(5, length(Ents)), + %% the biggest owner is first, with one table + ?assertEqual(list_to_binary(pid_to_list(Big)), maps:get(<<"pid">>, First)), + #{<<"tables">> := 1, <<"memory">> := W, <<"memoryUnit">> := <<"words">>, <<"memoryBytes">> := Bytes} = + maps:get(<<"ets">>, First), + ?assert(W > 1000), + ?assertEqual(W * erlang:system_info(wordsize), Bytes), + ?assertEqual(1, maps:get(<<"rank">>, First)), + Mems = [maps:get(<<"memory">>, maps:get(<<"ets">>, E)) || E <- Ents], + ?assertEqual(lists:reverse(lists:sort(Mems)), Mems), + #{<<"tableCount">> := T, <<"ownerCount">> := O, <<"totalMemory">> := #{<<"words">> := TW, <<"bytes">> := _}} = + maps:get(<<"scope">>, S), + ?assert(T >= 3 andalso O >= 2 andalso TW >= W), + %% aggregates only: no table name (named, private or hidden), key or value anywhere + Dump = iolist_to_binary(io_lib:format("~p", [S])), + [?assertEqual(nomatch, binary:match(Dump, X)) + || X <- [<<"mcp_fx_hidden_big">>, <<"mcp_fx_orders">>, <<"mcp_fx_private">>, <<"SENTINEL">>]], + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"ets_summary">>, #{<<"limit">> => 51})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"ets_summary">>, #{<<"table">> => <<"x">>})) + after + exit(Big, kill) + end. + +ets_summary_counts_only_approved_tables(Config) -> + Self = self(), + Big = spawn(fun() -> + ets:new(mcp_fx_hidden_big, [named_table, public]), + ets:insert(mcp_fx_hidden_big, {1, "SENTINEL_BIG_VALUE"}), + Self ! ready, + receive stop -> ok end + end), + receive ready -> ok after 5000 -> ct:fail(no_big_table) end, + try + S = ok_call(Config, <<"ets_summary">>, #{<<"limit">> => 50}), + Ents = maps:get(<<"entities">>, S), + %% only the owner of the approved table mcp_fx_orders is counted + ?assertEqual([<<"mcp_fx_worker_c">>], [maps:get(<<"name">>, E) || E <- Ents]), + ?assertMatch(#{<<"ets">> := #{<<"tables">> := 1}}, hd(Ents)), + ?assertNot(lists:any(fun(E) -> maps:get(<<"pid">>, E, undefined) =:= list_to_binary(pid_to_list(Big)) end, Ents)), + ?assert(lists:any(fun(#{<<"reason">> := R}) -> R =:= <<"policy_denied">> end, maps:get(<<"omissions">>, S))) + after + exit(Big, kill) + end. + +developer_tools_are_off_by_default(Config) -> + #{<<"result">> := #{<<"tools">> := Tools}} = rpc(Config, <<"tools/list">>, #{}), + Names = [maps:get(<<"name">>, T) || T <- Tools], + [?assertEqual(false, lists:member(T, Names)) || T <- [<<"process_state">>, <<"mailbox_sample">>, <<"ets_sample">>]], + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_state">>, #{<<"name">> => <<"mcp_fx_worker_a">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"mailbox_sample">>, #{<<"name">> => <<"mcp_fx_mailbox">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"ets_sample">>, #{<<"table">> => <<"mcp_fx_orders">>})). + +process_state_is_bounded_redacted_and_never_for_supervisors(Config) -> + S = ok_call(Config, <<"process_state">>, #{<<"name">> => <<"mcp_fx_worker_a">>}), + ?assertMatch(#{<<"behaviour">> := <<"gen_server">>, <<"callbackModule">> := <<"mcp_fixture_worker">>, + <<"name">> := <<"mcp_fx_worker_a">>, <<"process">> := _, <<"state">> := _}, S), + %% the state is a map with a `secret` key: its value is replaced, the orphan pid stays visible + Dump = iolist_to_binary(io_lib:format("~p", [S])), + ?assertEqual(nomatch, binary:match(Dump, <<"SENTINEL">>)), + ?assertNotEqual(nomatch, binary:match(Dump, <<"">>)), + %% by id too; no dictionary, never a supervisor (its state holds child start arguments) + Id = maps:get(<<"process">>, S), + ?assertMatch(#{<<"state">> := _}, ok_call(Config, <<"process_state">>, #{<<"id">> => Id})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"use_supervision_tree">>}}}, + call(Config, <<"process_state">>, #{<<"name">> => <<"mcp_fx_sub_sup">>})), + %% a plain process is never sent a system message + Orphan = whereis(mcp_fx_orphan), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_an_otp_process">>}}}, + call(Config, <<"process_state">>, #{<<"name">> => <<"mcp_fx_orphan">>})), + ?assertEqual({messages, []}, erlang:process_info(Orphan, messages)), + %% inspector processes, exited processes, hostile names and strict arguments + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"process_state">>, #{<<"name">> => <<"mcp_server">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"process_state">>, #{<<"name">> => <<"no_such_name_zz">>})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_state">>, #{})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"process_state">>, #{<<"name">> => <<"a">>, <<"pid">> => <<"<0.1.0>">>})). + +mailbox_sample_returns_the_oldest_messages_redacted(Config) -> + Hot = spawn(fun() -> register(mcp_fx_mail, self()), receive never -> ok end end), + wait_until(fun() -> whereis(mcp_fx_mail) =:= Hot end), + Hot ! {password, "SENTINEL_PW"}, + Hot ! #{token => "SENTINEL_TOKEN", ok => 1}, + Hot ! {url, "postgres://user:SENTINEL_URLPW@db/app"}, + [Hot ! {n, I} || I <- lists:seq(1, 27)], + try + S = ok_call(Config, <<"mailbox_sample">>, #{<<"name">> => <<"mcp_fx_mail">>, <<"limit">> => 4}), + ?assertMatch(#{<<"queueLength">> := 30, <<"truncated">> := true, <<"order">> := <<"oldest first">>}, S), + ?assertEqual(4, length(maps:get(<<"sample">>, S))), + Dump = iolist_to_binary(io_lib:format("~p", [S])), + ?assertEqual(nomatch, binary:match(Dump, <<"SENTINEL">>)), + ?assertNotEqual(nomatch, binary:match(Dump, <<"">>)), + %% the default sample is 5, the maximum 20; the mailbox itself is unchanged + ?assertEqual(5, length(maps:get(<<"sample">>, ok_call(Config, <<"mailbox_sample">>, #{<<"name">> => <<"mcp_fx_mail">>})))), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"mailbox_sample">>, #{<<"name">> => <<"mcp_fx_mail">>, <<"limit">> => 21})), + ?assertEqual({message_queue_len, 30}, erlang:process_info(Hot, message_queue_len)), + %% a quiet mailbox of the fixture holds its message + M = ok_call(Config, <<"mailbox_sample">>, #{<<"name">> => <<"mcp_fx_mailbox">>}), + ?assertMatch(#{<<"queueLength">> := 1, <<"sample">> := [<<"SENTINEL_MAILBOX">>], <<"truncated">> := false}, M) + after + exit(Hot, kill) + end. + +ets_sample_only_for_approved_non_private_tables(Config) -> + S = ok_call(Config, <<"ets_sample">>, #{<<"table">> => <<"mcp_fx_orders">>}), + ?assertMatch(#{<<"table">> := <<"mcp_fx_orders">>, <<"protection">> := <<"public">>, <<"size">> := 1, + <<"truncated">> := false}, S), + ?assertEqual(1, length(maps:get(<<"sample">>, S))), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"policy_denied">>}}}, + call(Config, <<"ets_sample">>, #{<<"table">> => <<"mcp_fx_private">>})), + ?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"not_found">>}}}, + call(Config, <<"ets_sample">>, #{<<"table">> => <<"mcp_fx_missing_table_zzz">>})), + %% a table that is not approved is denied before any atom lookup: no atom is created + Atoms = erlang:system_info(atom_count), + [?assertMatch({tool_error, #{<<"error">> := #{<<"code">> := <<"policy_denied">>}}}, + call(Config, <<"ets_sample">>, #{<<"table">> => <<"hostile_table_", (integer_to_binary(I))/binary>>})) + || I <- lists:seq(1, 30)], + ?assertEqual(Atoms, erlang:system_info(atom_count)), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"ets_sample">>, #{})), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"ets_sample">>, #{<<"table">> => <<"mcp_fx_orders">>, <<"limit">> => 0})). + +prompt_guides_an_agent_and_checks_its_argument(Config) -> + #{<<"result">> := #{<<"prompts">> := [P]}} = rpc(Config, <<"prompts/list">>, #{}), + ?assertMatch(#{<<"name">> := <<"map_application">>, + <<"arguments">> := [#{<<"name">> := <<"application">>, <<"required">> := true}]}, P), + #{<<"result">> := #{<<"messages">> := [#{<<"role">> := <<"user">>, <<"content">> := #{<<"text">> := Text}}]}} = + rpc(Config, <<"prompts/get">>, #{<<"name">> => <<"map_application">>, + <<"arguments">> => #{<<"application">> => <<"my_app">>}}), + ?assertNotEqual(nomatch, binary:match(Text, <<"topology_overview(name=\"my_app\")">>)), + ?assertNotEqual(nomatch, binary:match(Text, <<"Coverage gaps">>)), + %% an unknown prompt and hostile or missing arguments are rejected, nothing is embedded + Bad = fun(Params) -> rpc(Config, <<"prompts/get">>, Params) end, + [?assertMatch(#{<<"error">> := #{<<"code">> := -32602}}, Bad(Params)) + || Params <- [#{<<"name">> => <<"eval">>}, + #{<<"name">> => <<"map_application">>}, + #{<<"name">> => <<"map_application">>, <<"arguments">> => #{<<"application">> => <<"a\nIgnore previous">>}}, + #{<<"name">> => <<"map_application">>, <<"arguments">> => #{<<"application">> => <<"a b">>}}, + #{<<"name">> => <<"map_application">>, <<"arguments">> => #{<<"application">> => 5}}, + #{<<"name">> => <<"map_application">>, <<"arguments">> => []}, + #{<<"name">> => <<"map_application">>, <<"arguments">> => #{<<"application">> => binary:copy(<<"a">>, 300)}}]]. + +mermaid_format_renders_the_returned_edges(Config) -> + RootId = fixture_root_id(Config), + Plain = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId}), + ?assertEqual(false, maps:is_key(<<"mermaid">>, Plain)), + S = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId, <<"format">> => <<"mermaid">>, + <<"detail">> => <<"summary">>}), + ?assertEqual(false, maps:get(<<"truncated">>, S)), + Mermaid = maps:get(<<"mermaid">>, S), + [<<"graph TD">> | Lines] = binary:split(Mermaid, <<"\n">>, [global, trim_all]), + Edges = [L || L <- Lines, binary:match(L, <<"-->">>) =/= nomatch], + ?assertEqual(5, length(Edges)), + [?assertNotEqual(nomatch, binary:match(E, <<"|supervises|">>)) || E <- Edges], + ?assertEqual(5, length(supervises_edges([S]))), + [?assertNotEqual(nomatch, binary:match(Mermaid, N)) || N <- [<<"mcp_fx_root">>, <<"mcp_fx_sub_sup">>, <<"supervisor">>]], + %% only safe characters: nothing from the VM can break out of a label + ?assertEqual(nomatch, re:run(binary:replace(Mermaid, <<"
">>, <<" ">>, [global]), "[<>\"{}`;]\\w*[<>{}`;]", + [{capture, none}])), + ?assertEqual(nomatch, binary:match(Mermaid, <<"SENTINEL">>)), + %% a relationship whose endpoint is on another page still gets a node + Small = ok_call(Config, <<"supervision_tree">>, #{<<"id">> => RootId, <<"format">> => <<"mermaid">>, + <<"pageSize">> => 3}), + ?assertNotEqual(nomatch, binary:match(maps:get(<<"mermaid">>, Small), <<"graph TD">>)), + ?assertMatch({rpc_error, -32602, _}, call(Config, <<"supervision_tree">>, #{<<"id">> => RootId, <<"format">> => <<"dot">>})), + %% other graph tools accept it too + ?assertMatch(#{<<"mermaid">> := _}, ok_call(Config, <<"top_processes">>, #{<<"format">> => <<"mermaid">>})). + +paused_process_is_reported_from_debugger_evidence(Config) -> + Quiet = ok_call(Config, <<"debug_session">>, #{}), + ?assertEqual([], maps:get(<<"pausedProcesses">>, Quiet)), + Mod = mcp_fixture_paused, + {module, Mod} = code:ensure_loaded(Mod), + case catch int:i(Mod) of + {module, Mod} -> + try + ok = int:auto_attach([break], {Mod, handler, []}), + ok = int:break(Mod, Mod:break_line()), + Pid = spawn(fun() -> Mod:run() end), + Line = Mod:break_line(), + wait_until(fun() -> + case ok_call(Config, <<"debug_session">>, #{}) of + #{<<"pausedProcesses">> := [_ | _]} -> true; + _ -> false + end + end), + #{<<"pausedProcesses">> := [Paused]} = ok_call(Config, <<"debug_session">>, #{}), + ?assertMatch(#{<<"status">> := <<"break">>, <<"module">> := <<"mcp_fixture_paused">>, + <<"line">> := Line, <<"id">> := _}, Paused), + %% the same process id joins with process_info, which names the breakpoint + Info = ok_call(Config, <<"process_info">>, + #{<<"pid">> => list_to_binary(pid_to_list(Pid))}), + PidText = list_to_binary(pid_to_list(Pid)), + [E] = [X || #{<<"pid">> := T} = X <- maps:get(<<"entities">>, Info), T =:= PidText], + ?assertEqual(maps:get(<<"id">>, Paused), id_of(E)), + ?assertMatch(#{<<"debugger">> := #{<<"status">> := <<"break">>, + <<"module">> := <<"mcp_fixture_paused">>, + <<"line">> := Line}}, E), + %% an ordinary process has no debugger field + Other = ok_call(Config, <<"process_info">>, #{<<"name">> => <<"mcp_fx_worker_c">>}), + ?assertEqual(false, maps:is_key(<<"debugger">>, ent(<<"mcp_fx_worker_c">>, maps:get(<<"entities">>, Other)))), + exit(Pid, kill) + after + catch int:auto_attach(false), + catch int:no_break(Mod), + catch int:n(Mod) + end; + Other2 -> + {skip, {debugger_unavailable, Other2}} + end. + +collect_journal(0) -> []; +collect_journal(N) -> + receive {journal, M} -> [M | collect_journal(N - 1)] + after 3000 -> ct:fail({missing_journal_entries, N}) + end. + +wait_until(Fun) -> wait_until(Fun, 5000). + +wait_until(Fun, Timeout) -> + Deadline = erlang:monotonic_time(millisecond) + Timeout, + wait_loop(Fun, Deadline). + +wait_loop(Fun, Deadline) -> + case Fun() of + true -> ok; + _ -> + case erlang:monotonic_time(millisecond) > Deadline of + true -> ct:fail(wait_until_timeout); + false -> timer:sleep(50), wait_loop(Fun, Deadline) + end + end. diff --git a/apps/erlangbridge/test/mcp/mcp_store_SUITE.erl b/apps/erlangbridge/test/mcp/mcp_store_SUITE.erl new file mode 100644 index 00000000..719a3fca --- /dev/null +++ b/apps/erlangbridge/test/mcp/mcp_store_SUITE.erl @@ -0,0 +1,131 @@ +-module(mcp_store_SUITE). + +%% Session state (mcp_store): bounded identities, retained collections and signed cursors, worker slots. + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-compile([export_all, nowarn_export_all]). + +all() -> + [identity_is_stable_and_bounded, + collection_count_and_ttl_budgets, + collection_byte_budget_truncates_with_non_resumable_omission, + cursors_are_bound_to_session_collection_tool_and_arguments, + slots_are_bounded_and_released_when_the_owner_dies, + members_are_bounded]. + +init_per_testcase(Case, Config) -> + L = (mcp_policy:default_limits())#{max_items => 2, max_collections => 2, collection_ttl_ms => 400, + max_result_bytes => 4096, max_collection_bytes => 4096, + max_binary_bytes => 512, max_concurrency => 2}, + Cfg = (mcp_policy:defaults())#{limits => L}, + {ok, Pid} = mcp_store:start_link(Cfg, launch), + unlink(Pid), + [{store, Pid}, {case_name, Case} | Config]. + +end_per_testcase(_, Config) -> + mcp_store:stop(), + _ = ?config(store, Config), + ok. + +meta() -> #{scope => #{}, started_at => 1, finished_at => 2, complete => true, truncated => false, omissions => []}. + +identity_is_stable_and_bounded(_) -> + A = mcp_store:entity_id(process, {process, self()}), + ?assertEqual(A, mcp_store:entity_id(process, {process, self()})), + ?assertMatch(<<"p_", _/binary>>, A), + ?assertMatch(<<"t_", _/binary>>, mcp_store:entity_id(ets_table, {ets, x, 1})), + ?assertMatch(<<"a_", _/binary>>, mcp_store:entity_id(application, {application, kernel})), + ?assertMatch(<<"m_", _/binary>>, mcp_store:entity_id(module, {module, lists})), + ?assertEqual({ok, {process, self()}}, mcp_store:resolve_id(A)), + %% a recreated entity (different key) gets a different id + ?assertNotEqual(A, mcp_store:entity_id(process, {process, spawn(fun() -> ok end)})), + %% bookkeeping is bounded (max_items * 10 = 20): the oldest handles expire explicitly + Ids = [mcp_store:entity_id(process, {process, {fake, I}}) || I <- lists:seq(1, 100)], + ?assert(maps:get(ids, mcp_store:stats()) =< 20), + ?assertEqual(expired, mcp_store:resolve_id(A)), + ?assertEqual(expired, mcp_store:resolve_id(hd(Ids))), + ?assertMatch({ok, _}, mcp_store:resolve_id(lists:last(Ids))), + ?assertEqual(expired, mcp_store:resolve_id(<<"p_unknown">>)), + ?assertEqual(expired, mcp_store:resolve_id(42)), + ?assertEqual(expired, mcp_store:resolve_id(undefined)). + +collection_count_and_ttl_budgets(_) -> + Items = [{entity, #{<<"id">> => <<"x">>}}], + {C1, _, _} = mcp_store:put_collection(<<"t">>, <<"h">>, meta(), Items, #{}), + {C2, _, _} = mcp_store:put_collection(<<"t">>, <<"h">>, meta(), Items, #{}), + ?assertMatch({ok, _}, mcp_store:fetch_collection(C1, <<"t">>, <<"h">>)), + {C3, _, _} = mcp_store:put_collection(<<"t">>, <<"h">>, meta(), Items, #{}), + %% count budget (2): the oldest is evicted + ?assertEqual({error, cursor_expired}, mcp_store:fetch_collection(C1, <<"t">>, <<"h">>)), + ?assertMatch({ok, _}, mcp_store:fetch_collection(C2, <<"t">>, <<"h">>)), + %% bound to tool and args + ?assertEqual({error, cursor_expired}, mcp_store:fetch_collection(C3, <<"other">>, <<"h">>)), + ?assertEqual({error, cursor_expired}, mcp_store:fetch_collection(C3, <<"t">>, <<"other">>)), + %% TTL (400 ms) + timer:sleep(600), + ?assertEqual({error, cursor_expired}, mcp_store:fetch_collection(C3, <<"t">>, <<"h">>)), + ?assertEqual(0, maps:get(collections, mcp_store:stats())), + ?assertEqual(0, maps:get(collection_bytes, mcp_store:stats())). + +collection_byte_budget_truncates_with_non_resumable_omission(_) -> + Items = [{entity, #{<<"id">> => integer_to_binary(I), <<"pad">> => binary:copy(<<"p">>, 300)}} || I <- lists:seq(1, 100)], + {C, Meta, Stored} = mcp_store:put_collection(<<"t">>, <<"h">>, meta(), Items, #{}), + ?assert(length(Stored) < 100), + ?assert(length(Stored) > 0), + ?assertEqual(false, maps:get(complete, Meta)), + ?assertEqual(true, maps:get(truncated, Meta)), + [Om] = maps:get(omissions, Meta), + ?assertMatch(#{<<"reason">> := <<"limit_reached">>, <<"resumable">> := false}, Om), + ?assertEqual(100 - length(Stored), maps:get(<<"count">>, Om)), + ?assert(maps:get(collection_bytes, mcp_store:stats()) =< 4096), + {ok, #{items := Kept}} = mcp_store:fetch_collection(C, <<"t">>, <<"h">>), + ?assertEqual(Stored, Kept). + +cursors_are_bound_to_session_collection_tool_and_arguments(_) -> + Cur = mcp_store:make_cursor(<<"c_1">>, <<"tool">>, <<"args">>, 10), + ?assertEqual({ok, <<"c_1">>, 10}, mcp_store:parse_cursor(Cur, <<"tool">>, <<"args">>)), + ?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(Cur, <<"tool2">>, <<"args">>)), + ?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(Cur, <<"tool">>, <<"args2">>)), + Other = mcp_store:make_cursor(<<"c_2">>, <<"tool">>, <<"args">>, 10), + [_, _, _, Sig1] = binary:split(Cur, <<".">>, [global]), + [_, _, _, Sig2] = binary:split(Other, <<".">>, [global]), + ?assertNotEqual(Sig1, Sig2), + %% a different offset with the same signature is rejected + Forged = binary:replace(Cur, <<".10.">>, <<".11.">>), + ?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(Forged, <<"tool">>, <<"args">>)), + [?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(Bad, <<"tool">>, <<"args">>)) + || Bad <- [<<>>, <<"c1">>, <<"c1.a.b.c">>, <<"c1.a.-5.sig">>, <<"c2.c_1.10.", Sig1/binary>>, + binary:copy(<<"a">>, 5000), <<"c1.", (binary:copy(<<"a">>, 100))/binary, ".1.sig">>]], + ?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(not_binary, <<"tool">>, <<"args">>)), + %% a new session (new secret) rejects it + mcp_store:stop(), + {ok, Pid} = mcp_store:start_link((mcp_policy:defaults()), launch), + unlink(Pid), + ?assertEqual({error, invalid_cursor}, mcp_store:parse_cursor(Cur, <<"tool">>, <<"args">>)). + +slots_are_bounded_and_released_when_the_owner_dies(_) -> + P1 = spawn(fun() -> receive stop -> ok end end), + P2 = spawn(fun() -> receive stop -> ok end end), + ?assertEqual(ok, mcp_store:acquire_slot(P1)), + ?assertEqual(ok, mcp_store:acquire_slot(P2)), + ?assertEqual(busy, mcp_store:acquire_slot(self())), + ?assertEqual(2, maps:get(slots, mcp_store:stats())), + P1 ! stop, + wait(fun() -> maps:get(slots, mcp_store:stats()) =:= 1 end), + ?assertEqual(ok, mcp_store:acquire_slot(self())), + mcp_store:release_slot(self()), + wait(fun() -> maps:get(slots, mcp_store:stats()) =:= 1 end), + exit(P2, kill), + wait(fun() -> maps:get(slots, mcp_store:stats()) =:= 0 end). + +members_are_bounded(_) -> + [mcp_store:note_member(list_to_pid("<0.1." ++ integer_to_list(I) ++ ">"), app, I) || I <- lists:seq(1, 6000)], + ?assert(maps:get(members, mcp_store:stats()) =< 5000), + ?assertMatch({app, _}, mcp_store:member(list_to_pid("<0.1.6000>"))), + ?assertEqual(undefined, mcp_store:member(list_to_pid("<0.1.1>"))). + +wait(F) -> wait(F, 50). +wait(_, 0) -> ct:fail(never); +wait(F, N) -> case F() of true -> ok; false -> timer:sleep(50), wait(F, N - 1) end. diff --git a/grammar b/grammar index 8e92d1c3..49f7a9ab 160000 --- a/grammar +++ b/grammar @@ -1 +1 @@ -Subproject commit 8e92d1c3bfe1d84b80c200edb65f4f504f3ba7d5 +Subproject commit 49f7a9ab6a69a11e097482a1e885e2321cbb1dd7 diff --git a/images/vscode-erlang-mcp-results.png b/images/vscode-erlang-mcp-results.png new file mode 100644 index 00000000..c9544f86 Binary files /dev/null and b/images/vscode-erlang-mcp-results.png differ diff --git a/images/vscode-erlang-mcp-running.png b/images/vscode-erlang-mcp-running.png new file mode 100644 index 00000000..8cd9cd19 Binary files /dev/null and b/images/vscode-erlang-mcp-running.png differ diff --git a/lib/ErlangConfigurationProvider.ts b/lib/ErlangConfigurationProvider.ts index 00ffce72..bd9bfd00 100644 --- a/lib/ErlangConfigurationProvider.ts +++ b/lib/ErlangConfigurationProvider.ts @@ -1,7 +1,7 @@ import { - workspace, WorkspaceFolder, DebugConfiguration, DebugConfigurationProvider, CancellationToken, ProviderResult, WorkspaceConfiguration + window, workspace, WorkspaceFolder, DebugConfiguration, DebugConfigurationProvider, CancellationToken, ProviderResult, WorkspaceConfiguration } from 'vscode'; -import { ErlangSettings } from './erlangSettings'; +import { ErlangSettings, ErlangMcpSettings } from './erlangSettings'; import { stringify } from 'querystring'; //import { ErlangOutput } from './vscodeAdapter'; @@ -21,10 +21,37 @@ export class ErlangDebugConfigurationProvider implements DebugConfigurationProvi debugConfiguration.verbose = cfg.verbose; debugConfiguration.erlangPath = cfg.erlangPath; debugConfiguration.useShell = cfg.useShell; + applyMcpSettings(folder, debugConfiguration); return debugConfiguration; } }; +/** + * erlang.mcp.* is the only switch of the MCP inspector, resolved for the debug + * session's own workspace folder (not the first folder of a multi-root + * workspace). It reaches the adapter as an internal property; a user-authored + * `mcp`/`mcpSettings` block in launch.json can neither enable nor tune it. + */ +export function applyMcpSettings(folder: WorkspaceFolder | undefined, debugConfiguration: DebugConfiguration): void { + if (debugConfiguration.mcp !== undefined || debugConfiguration.mcpSettings !== undefined) { + window.showWarningMessage("Erlang: an 'mcp' block in a launch configuration is not supported and was ignored; use the 'erlang.mcp.*' settings."); + } + delete debugConfiguration.mcp; + delete debugConfiguration.mcpSettings; + const mcp = workspace.getConfiguration("erlang", folder?.uri); + const settings: ErlangMcpSettings = { + enabled: mcp.get("mcp.enabled", false) === true, + host: mcp.get("mcp.host", "127.0.0.1"), + port: mcp.get("mcp.port", 0), + authToken: mcp.get("mcp.authToken", "") || undefined, + trusted: workspace.isTrusted, + root: folder ? folder.uri.fsPath : (getElangConfigConfiguration().rootPath ?? "") + }; + if (settings.enabled && !debugConfiguration.noDebug) { + debugConfiguration.mcpSettings = settings; + } +} + let currentSettings: ErlangSettings = null; // "auto" matches prior (unconfigurable) behavior: a shell is only needed on diff --git a/lib/ErlangShellDebugger.ts b/lib/ErlangShellDebugger.ts index 1291e0f8..7e8be5be 100644 --- a/lib/ErlangShellDebugger.ts +++ b/lib/ErlangShellDebugger.ts @@ -2,6 +2,7 @@ import { ChildProcess, spawn } from 'child_process'; import { EventEmitter } from 'events'; import { GenericShell, ILogOutput } from './GenericShell'; import { DebugProtocol } from '@vscode/debugprotocol'; +import { ErlangMcpSettings } from './erlangSettings'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; @@ -17,6 +18,7 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum addEbinsToCodepath: boolean; erlangPath : string; // path of erlang if specified in configuration useShell: boolean; // resolved from erlang.useShell (see ErlangConfigurationProvider) + mcpSettings?: ErlangMcpSettings; // internal, resolved from erlang.mcp.* (see ErlangConfigurationProvider) } /** `attach` request: debug a node that is already running on this machine. */ @@ -28,6 +30,7 @@ export interface AttachRequestArguments extends DebugProtocol.AttachRequestArgum verbose: boolean; erlangPath : string; useShell: boolean; + mcpSettings?: ErlangMcpSettings; } // Values end up on a shell command line when this shell is spawned through @@ -127,12 +130,55 @@ export class ErlangShellForDebugging extends GenericShell { "-pa", this.shellQuote(bridgePath), "-vscode_port", listen_port.toString(), "-vscode_attach_node", args.node, - "-compiled_args_file", this.shellQuote(this.argsPrecompiledFileName), - "-s", "vscode_connection", "attach"); + "-compiled_args_file", this.shellQuote(this.argsPrecompiledFileName)); + if (args.mcpSettings?.enabled) { + // makes the helper push the MCP inspector modules to the target (only then) + processArgs.push("-vscode_mcp", "1"); + } + processArgs.push("-s", "vscode_connection", "attach"); this.started = true; return this.LaunchProcess(erlPath, startDir, processArgs, !args.verbose); } + /** + * Resolve `erlang.mcp.*` + the project's rebar.config policy in a short-lived + * helper VM (the policy reader interns atoms, so it never runs in the LSP or + * target VM). Erlang (mcp_policy) is authoritative; only its normalized, + * non-secret result comes back. + */ + public ResolveMcpPolicy(erlPath: string, cwd: string, bridgePath: string, mcp: ErlangMcpSettings): Promise<{ status: string, config?: any, message?: string }> { + return new Promise((resolve) => { + const fail = (message: string) => resolve({ status: 'error', message }); + try { + let env = process.env; + if (this.erlangPath) { + env = { ...process.env, PATH: this.erlangPath + (process.platform == 'win32' ? ";" : ":") + process.env.PATH }; + } + const child = spawn(erlPath, ["-noshell", "-pa", this.shellQuote(bridgePath), "-s", "mcp_policy", "cli"], + { cwd: cwd, shell: this.useShell, stdio: 'pipe', env }); + let out = ""; + const timer = setTimeout(() => { child.kill(); fail("MCP policy resolution timed out"); }, 20000); + child.stdout.on('data', d => { out += d.toString('utf8'); }); + child.on('error', () => { clearTimeout(timer); fail("MCP policy helper cannot be started"); }); + child.on('close', () => { + clearTimeout(timer); + const line = out.split(/\r?\n/).reverse().find(l => l.startsWith('{')); + try { + resolve(line ? JSON.parse(line) : { status: 'error', message: "MCP policy could not be resolved" }); + } catch { + fail("MCP policy could not be resolved"); + } + }); + child.stdin.end(JSON.stringify({ + enabled: mcp.enabled, host: mcp.host, port: mcp.port, trusted: mcp.trusted, + root: mcp.root, cwd: cwd + }) + "\n"); + } catch { + fail("MCP policy helper cannot be started"); + } + }); + } + /** The helper node halts when its stdin closes; the attached node keeps running. */ public EndAttach() { this.childProcess?.stdin?.end(); diff --git a/lib/GenericShell.ts b/lib/GenericShell.ts index df749f56..078cbbe2 100644 --- a/lib/GenericShell.ts +++ b/lib/GenericShell.ts @@ -103,6 +103,11 @@ export function commandLineArgs(value: string | undefined, useShell: boolean): s return useShell ? [value] : splitCommandLine(value); } +// Arguments that carry a secret (the Erlang cookie) must not reach the logs. +export function redactArgs(args: string[]): string[] { + return args.map((a, i) => (i > 0 && args[i - 1] === '-setcookie') ? '' : a); +} + export class GenericShell extends EventEmitter { protected childProcess: ChildProcess; protected logOutput: ILogOutput; @@ -182,7 +187,7 @@ export class GenericShell extends EventEmitter { if (this.erlangPath) { this.log("log",`using erlang binaries from path : '${this.erlangPath}'`); } - this.log("log", `starting : ${processName} \r\n` + args.join(" ")); + this.log("log", `starting : ${processName} \r\n` + redactArgs(args).join(" ")); } var childEnv = null; if (this.erlangPath) { diff --git a/lib/erlangDebugConnection.ts b/lib/erlangDebugConnection.ts index 63856328..11b6ca0d 100644 --- a/lib/erlangDebugConnection.ts +++ b/lib/erlangDebugConnection.ts @@ -5,7 +5,11 @@ import { FunctionBreakpoint } from './ErlangShellDebugger'; export class ErlangDebugConnection extends ErlangConnection { protected get_ErlangFiles(): string[] { - return ["gen_connection.erl", "vscode_connection.erl", "vscode_jsone.erl"]; + // mcp/* is the embedded MCP inspector: compiled here (debugger bridge beams), + // never loaded by the LSP node, only started on request for an enabled debug session + return ["gen_connection.erl", "vscode_connection.erl", "vscode_jsone.erl", "vscode_jsone_decode.erl", + "mcp/mcp_policy.erl", "mcp/mcp_encoder.erl", "mcp/mcp_audit.erl", "mcp/mcp_store.erl", + "mcp/mcp_tools.erl", "mcp/mcp_runtime.erl", "mcp/mcp_server.erl", "mcp/mcp_sup.erl"]; } protected handle_erlang_event(url: string, body : any) : void { @@ -32,6 +36,10 @@ export class ErlangDebugConnection extends ErlangConnection { break; case "/delete_break": break; + case "/mcp_call": + // journal of the MCP inspector: request metadata only (no arguments, results or token) + this.emit("mcp_call", body); + break; case "/fbp_verified": this.emit("fbp_verified", body.module, body.name, body.arity); break; @@ -171,6 +179,22 @@ export class ErlangDebugConnection extends ErlangConnection { }); } + /** Start the MCP inspector in the target; the reply (with the session token) goes to the adapter only. */ + public mcpStart(config: any, mode: string): Promise<{ ok: boolean, error?: string, host?: string, port?: number, path?: string, sessionId?: string, token?: string }> { + return this.post("mcp_start", JSON.stringify({ ...config, mode })).catch(err => { + return { ok: false, error: "the MCP inspector could not be started" }; + }); + } + + /** Lease renewal: without it the target stops the inspector within ~10 seconds. */ + public mcpRenew(): Promise { + return this.post("mcp_renew", "").then(r => !!r?.ok, () => false); + } + + public mcpStop(): Promise { + return this.post("mcp_stop", "").then(() => undefined, () => undefined); + } + public closeEventsReceiver(): void { this.events_receiver?.close(); } diff --git a/lib/erlangDebugSession.ts b/lib/erlangDebugSession.ts index fcedf01f..c70166d3 100644 --- a/lib/erlangDebugSession.ts +++ b/lib/erlangDebugSession.ts @@ -1,13 +1,14 @@ import { DebugSession, InitializedEvent, TerminatedEvent, StoppedEvent, OutputEvent, Thread, ThreadEvent, StackFrame, Scope, Source, Handles - , Breakpoint, ModuleEvent, Module, ContinuedEvent, Variable, BreakpointEvent + , Breakpoint, ModuleEvent, Module, ContinuedEvent, Variable, BreakpointEvent, Event } from '@vscode/debugadapter'; import { DebugProtocol } from '@vscode/debugprotocol'; import { ErlangShellForDebugging, LaunchRequestArguments, AttachRequestArguments, FunctionBreakpoint, validateAttachArguments } from './ErlangShellDebugger'; import { ILogOutput } from './GenericShell'; import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import { erlangBridgePath, setExtensionPath } from './erlangConnection'; import { ErlangDebugConnection } from './erlangDebugConnection'; @@ -20,6 +21,17 @@ interface DebugVariable { children: Variable[]; } +// Launch/attach arguments are logged in verbose mode: never print secrets. +const SECRET_KEY = /token|secret|password|cookie|authorization|credential/i; +export function sanitizeArguments(args: any): any { + const copy: any = {}; + Object.keys(args ?? {}).forEach(k => { + copy[k] = SECRET_KEY.test(k) ? '' + : (args[k] && typeof args[k] === 'object' && !Array.isArray(args[k])) ? sanitizeArguments(args[k]) : args[k]; + }); + return copy; +} + class ConditionalBreakpoint { condition: string; hitCount: number; @@ -50,6 +62,12 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { // set by an attach request: the debuggee is a running node we must not stop private _AttachArguments: AttachRequestArguments; private _port: number; + // embedded MCP inspector (debug sessions only): lease renewal + secret descriptor + private _mcpRenewTimer: NodeJS.Timeout; + private _mcpDescriptorDir: string; + private _mcpStarted = false; + private _mcpCalls = 0; + private _mcpServerMs = 0; public constructor(verbose: boolean) { super(); @@ -115,6 +133,7 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { this.erlangConnection.on("new_process", (arg) => this.onNewProcess(arg)); this.erlangConnection.on("new_status", (pid, status, reason, moduleName, line) => this.onNewStatus(pid, status, reason, moduleName, line)); this.erlangConnection.on("on_break", (pid, moduleName, line, stacktrace) => this.onBreak(pid, moduleName, line, stacktrace)); + this.erlangConnection.on("mcp_call", (meta) => this.onMcpCall(meta)); this.erlangConnection.on("fbp_verified", (moduleName, functionName, arity) => this.onFbpVerified(moduleName, functionName, arity)); response.body.supportsConfigurationDoneRequest = true; @@ -150,7 +169,7 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { } this._LaunchArguments = args; if (this._LaunchArguments.verbose) { - this.log(`debugger launchRequest arguments : ${JSON.stringify(args)}`); + this.log(`debugger launchRequest arguments : ${JSON.stringify(sanitizeArguments(args))}`); } // Based on JS output path, not TS path this.erlangConnection.Start(this._LaunchArguments.verbose, this._LaunchArguments.erlangPath).then(port => { @@ -183,7 +202,7 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { arguments: "", addEbinsToCodepath: false, noDebug: false }; if (args.verbose) { - this.log(`debugger attachRequest arguments : ${JSON.stringify(args)}`); + this.log(`debugger attachRequest arguments : ${JSON.stringify(sanitizeArguments(args))}`); } this.erlangConnection.Start(args.verbose, args.erlangPath).then(port => { this._port = port; @@ -232,6 +251,7 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { protected disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments): void { //this.debug("disconnectRequest"); + this.stopMcp(); if (this._AttachArguments) { // Detach unless asked to stop the node: stopping a node someone // else started must be an explicit choice. @@ -254,6 +274,7 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { protected quitEvent(exitCode: number) { this.log(`erl exit with code ${exitCode}`); + this.stopMcp(false); this.quit = true; this.sendEvent(new TerminatedEvent()); this.erlDebugger.CleanupAfterStart(); @@ -606,6 +627,79 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { private onStartListening(message: string): void { if (this._LaunchArguments.verbose) this.debug(message); + this.startMcp(); + } + + /** + * Start the embedded MCP inspector in the debugged node when (and only when) + * this is a real debug session and erlang.mcp.enabled is true for the folder. + * Every failure is non-fatal: it is reported and debugging goes on. + * The session token never travels in a DAP event: the extension host reads it + * from a 0600 descriptor that it deletes right away. + */ + private async startMcp(): Promise { + const mcp = this._AttachArguments?.mcpSettings ?? this._LaunchArguments?.mcpSettings; + if (!mcp?.enabled || this._LaunchArguments.noDebug || this._mcpStarted) { + return; + } + this._mcpStarted = true; + try { + const bridgeBinPath = path.normalize(path.join(erlangBridgePath, "..", "ebin")); + const policy = await this.erlDebugger.ResolveMcpPolicy(this._LaunchArguments.erlpath, this._LaunchArguments.cwd, bridgeBinPath, mcp); + if (policy.status !== 'ok') { + if (policy.status === 'error') { + this.mcpFailed(policy.message); + } + return; + } + const mode = this._AttachArguments ? 'attach' : 'launch'; + const started = await this.erlangConnection.mcpStart({ ...policy.config, ...(mcp.authToken ? { authToken: mcp.authToken } : {}) }, mode); + if (!started.ok) { + this.mcpFailed(started.error); + return; + } + const url = `http://${started.host!.includes(':') ? '[' + started.host + ']' : started.host}:${started.port}${started.path}`; + this._mcpDescriptorDir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-mcp-')); + const descriptor = path.join(this._mcpDescriptorDir, 'endpoint.json'); + fs.writeFileSync(descriptor, JSON.stringify({ url: url, token: started.token, sessionId: started.sessionId }), { mode: 0o600 }); + this._mcpRenewTimer = setInterval(() => { + this.erlangConnection.mcpRenew(); + }, 2000); + this.sendEvent(new Event('erlangMcp', { status: 'started', url: url, sessionId: started.sessionId, descriptor: descriptor })); + this.log(`MCP inspector listening on ${url} (session ${started.sessionId}); the credential is delivered to VS Code only`); + } catch (e) { + this.mcpFailed("the MCP inspector could not be started"); + } + } + + /** One MCP request handled by the inspector: forwarded to the extension host's "Erlang MCP" channel. */ + private onMcpCall(meta: any): void { + this._mcpCalls++; + this._mcpServerMs += typeof meta?.durationMs === 'number' ? meta.durationMs : 0; + this.sendEvent(new Event('erlangMcpCall', meta)); + } + + private mcpFailed(message: string): void { + this.log(`MCP is disabled for this debug session: ${message}`); + this.sendEvent(new Event('erlangMcp', { status: 'error', message: message })); + this.stopMcp(true); + } + + private stopMcp(notifyTarget: boolean = true): void { + if (this._mcpRenewTimer) { + clearInterval(this._mcpRenewTimer); + this._mcpRenewTimer = undefined; + } + if (this._mcpDescriptorDir) { + try { fs.rmSync(this._mcpDescriptorDir, { recursive: true, force: true }); } catch { /* best effort */ } + this._mcpDescriptorDir = undefined; + } + if (this._mcpStarted) { + if (notifyTarget && this.erlangConnection?.isConnected) { + this.erlangConnection.mcpStop(); + } + this.sendEvent(new Event('erlangMcp', { status: 'stopped', calls: this._mcpCalls, serverMs: this._mcpServerMs })); + } } private onNewModule(moduleName: string): void { @@ -741,7 +835,8 @@ export class ErlangDebugSession extends DebugSession implements ILogOutput { setTimeout(function () { var currentThread = that.threadIDs[processName]; delete that.threadIDs[processName]; - if (currentThread.vscode) { + // an exit can be reported for a process never announced (or already removed) + if (currentThread && currentThread.vscode) { that.sendEvent(new ThreadEvent("exited", currentThread.thid)); } var thCount = that.threadCount(); diff --git a/lib/erlangSettings.ts b/lib/erlangSettings.ts index fc93df7c..41a8ee76 100644 --- a/lib/erlangSettings.ts +++ b/lib/erlangSettings.ts @@ -1,3 +1,19 @@ +/** + * Internal, folder-resolved MCP settings handed to the debug adapter (never a + * user-facing launch/attach block). Validation is authoritative in Erlang (mcp_policy). + */ +export interface ErlangMcpSettings { + enabled: boolean; + host: string; + port: number; + /** optional fixed development token (erlang.mcp.authToken); validated in Erlang */ + authToken?: string; + /** Workspace Trust status of the folder */ + trusted: boolean; + /** selected workspace folder; the project policy is never read outside it */ + root: string; +} + export interface ErlangSettings { erlangPath : string; erlangArgs : string[]; diff --git a/lib/extension.ts b/lib/extension.ts index fe869551..a8ea4c63 100644 --- a/lib/extension.ts +++ b/lib/extension.ts @@ -1,9 +1,12 @@ // The module 'vscode' contains the VS Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; import { workspace as Workspace, window as Window, ExtensionContext, TextDocument, OutputChannel, WorkspaceFolder, Uri, debug, - languages, IndentAction, DebugAdapterDescriptorFactory + languages, IndentAction, DebugAdapterDescriptorFactory, commands, lm, EventEmitter, env, + McpHttpServerDefinition, DebugSession } from 'vscode'; import * as Adapter from './vscodeAdapter'; @@ -75,6 +78,7 @@ export function activate(context: ExtensionContext) { } disposables.forEach((disposable => context.subscriptions.push(disposable))); LspClient.activate(context); + activateMcpIntegration(context); languages.setLanguageConfiguration("erlang", { onEnterRules: [ @@ -184,6 +188,165 @@ export function activate(context: ExtensionContext) { }); } +// ---- Embedded MCP inspector: thin VS Code hooks (all MCP logic is Erlang) ---------------------------- +// The debug adapter announces the live endpoint with a custom DAP event (no secret in it) and leaves +// the session token in a 0600 descriptor that is read and deleted immediately; the token then lives +// in this process' memory only, and is handed to VS Code through the MCP provider API. +interface McpSession { label: string; url: string; sessionId: string; token: string; } +const mcpSessions = new Map(); + +// "Erlang MCP" output channel: one line per MCP request (metadata only), to see how many calls an +// agent makes and whether the time goes to the inspector (ms) or to the agent (+delta between calls). +let mcpChannel: OutputChannel | undefined; +const mcpJournal = new Map(); + +function mcpLog(line: string) { + if (!mcpChannel) { + mcpChannel = Window.createOutputChannel('Erlang MCP'); + } + mcpChannel.appendLine(line); +} + +function clock(t: number): string { + return new Date(t).toISOString().substring(11, 23); +} + +function seconds(ms: number): string { + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; +} + +function formatMcpCall(m: any, sinceLast: number | undefined): string { + const parts = [`#${m.seq ?? '?'}`, m.method === 'tools/call' ? `tools/call ${m.tool}` : `${m.method}`, `${m.status}`]; + if (typeof m.durationMs === 'number') { parts.push(`${m.durationMs} ms`); } + if (typeof m.bytes === 'number') { parts.push(m.bytes >= 1024 ? `${(m.bytes / 1024).toFixed(1)} KB` : `${m.bytes} B`); } + if (typeof m.entities === 'number') { parts.push(`ents ${m.entities} rels ${m.relationships}`); } + if (typeof m.offset === 'number') { parts.push(`page@${m.offset}${m.cursor ? ' (cursor)' : ''}${m.more ? ' +more' : ''}`); } + if (m.omissions) { parts.push(`omissions ${m.omissions}`); } + if (m.complete === false) { parts.push('partial'); } + if (sinceLast !== undefined) { parts.push(`(+${seconds(sinceLast)} since previous)`); } + return parts.join(' '); +} + +function activateMcpIntegration(context: ExtensionContext) { + removeStaleMcpDescriptors(); + const changed = new EventEmitter(); + context.subscriptions.push(changed); + + context.subscriptions.push(debug.onDidReceiveDebugSessionCustomEvent(e => { + if (e.event !== 'erlangMcp' && e.event !== 'erlangMcpCall') { + return; + } + if (e.event === 'erlangMcpCall') { + const journal = mcpJournal.get(e.session.id) ?? { start: Date.now() }; + const now = Date.now(); + mcpLog(`${clock(now)} ${formatMcpCall(e.body ?? {}, journal.last === undefined ? undefined : now - journal.last)}`); + journal.last = now; + mcpJournal.set(e.session.id, journal); + return; + } + const body = e.body ?? {}; + if (body.status === 'started') { + mcpJournal.set(e.session.id, { start: Date.now() }); + mcpLog(`── ${clock(Date.now())} session "${e.session.name}": ${body.url}`); + const session = readMcpDescriptor(body.descriptor, e.session); + if (session) { + mcpSessions.set(e.session.id, session); + } else { + Window.showWarningMessage('Erlang MCP: the endpoint credential could not be read; MCP is unavailable for this debug session.'); + } + } else if (body.status === 'error') { + Window.showWarningMessage(`Erlang MCP is disabled for this debug session: ${body.message}`); + } else if (body.status === 'stopped') { + mcpSessions.delete(e.session.id); + const journal = mcpJournal.get(e.session.id); + if (journal) { + mcpLog(`── ${clock(Date.now())} end of "${e.session.name}": ${body.calls ?? 0} calls, ` + + `${seconds(body.serverMs ?? 0)} in the inspector, ${seconds(Date.now() - journal.start)} of session`); + mcpJournal.delete(e.session.id); + } + } + changed.fire(); + })); + context.subscriptions.push(debug.onDidTerminateDebugSession(s => { + if (mcpSessions.delete(s.id)) { + changed.fire(); + } + })); + + // dynamic registration: needs VS Code with the MCP provider API (>= 1.101) + if (typeof lm?.registerMcpServerDefinitionProvider === 'function') { + context.subscriptions.push(lm.registerMcpServerDefinitionProvider('erlang.mcp', { + onDidChangeMcpServerDefinitions: changed.event, + provideMcpServerDefinitions: async () => + [...mcpSessions.values()].map(s => new McpHttpServerDefinition(s.label, Uri.parse(s.url))), + resolveMcpServerDefinition: async (server) => { + const session = [...mcpSessions.values()].find(s => s.label === server.label); + if (!session || !(server instanceof McpHttpServerDefinition)) { + return undefined; + } + server.headers = { Authorization: `Bearer ${session.token}` }; + return server; + } + })); + } + + context.subscriptions.push(commands.registerCommand('erlang.mcp.showConnectionDetails', async () => { + const sessions = [...mcpSessions.entries()]; + if (sessions.length === 0) { + Window.showInformationMessage('Erlang MCP: no debug session with an MCP inspector is running (enable erlang.mcp.enabled and start a debug session).'); + return; + } + let picked = sessions[0][1]; + if (sessions.length > 1) { + const item = await Window.showQuickPick(sessions.map(([, s]) => ({ label: s.label, description: s.url, session: s })), + { placeHolder: 'Select the debug session' }); + if (!item) { + return; + } + picked = item.session; + } + // The token is never shown or copied automatically: only on an explicit choice. + const choice = await Window.showInformationMessage( + `Erlang MCP (${picked.label}): ${picked.url}\nVS Code clients are registered automatically. For a static client, ` + + `use this URL with a fixed erlang.mcp.port and an "Authorization: Bearer " header (the token changes at every debug session).`, + 'Copy URL', 'Copy Authorization header'); + if (choice === 'Copy URL') { + await env.clipboard.writeText(picked.url); + } else if (choice === 'Copy Authorization header') { + await env.clipboard.writeText(`Authorization: Bearer ${picked.token}`); + } + })); +} + +function readMcpDescriptor(descriptor: string, session: DebugSession): McpSession | undefined { + try { + if (!descriptor || !path.basename(path.dirname(descriptor)).startsWith('erlang-mcp-')) { + return undefined; + } + const data = JSON.parse(fs.readFileSync(descriptor, 'utf8')); + return { label: `Erlang debug: ${session.name} (${session.id.substring(0, 8)})`, url: data.url, sessionId: data.sessionId, token: data.token }; + } catch { + return undefined; + } finally { + try { fs.rmSync(path.dirname(descriptor), { recursive: true, force: true }); } catch { /* best effort */ } + } +} + +/** Descriptors are deleted right after being read; clean up those left by a crash. */ +function removeStaleMcpDescriptors() { + try { + const tmp = os.tmpdir(); + for (const name of fs.readdirSync(tmp)) { + if (name.startsWith('erlang-mcp-')) { + const dir = path.join(tmp, name); + if (Date.now() - fs.statSync(dir).mtimeMs > 60000) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + } + } catch { /* best effort */ } +} + export function deactivate(): Thenable { return LspClient.deactivate(); } diff --git a/mcp_security.md b/mcp_security.md new file mode 100644 index 00000000..aac46c05 --- /dev/null +++ b/mcp_security.md @@ -0,0 +1,99 @@ +# MCP topology inspector: security note + +Scope: the opt-in, read-only MCP server embedded in the Erlang node of a **debug session** +(`erlang.mcp.enabled`, see [HELP.MD](./HELP.MD#mcp-topology-inspector-ai-agents-debug-sessions-only)). + +## Who is being protected from what + +The developer who turns this on owns the machine, the code and the node: the controls are **not** +there to stop the developer from looking at their own application (the debugger already shows +variables and can evaluate expressions). They exist because the *client* of this server is an AI +agent, and because a local HTTP port is reachable by more than the developer: + +1. **Data leaves the machine.** Whatever a tool returns goes into the agent's context, i.e. to a + model provider. This is the main reason the default is metadata only. +2. **A prompt-injected or malicious agent.** Source files, logs, issues or web pages the agent reads + can steer it. The server must stay harmless in the hands of such an agent: read-only, no + evaluation, no mutation, bounded cost. +3. **Browser pages** attacking `localhost` (DNS rebinding, cross-origin requests). +4. **Other processes of the same user**, compromised developer tools, an accidentally forwarded port. + +Binding to loopback is *not* an authorization boundary. All requests, parameters and identifiers are +untrusted. + +## Two tiers + +| Tier | What it returns | How it is enabled | +| --- | --- | --- | +| **Metadata** (default) | Topology and counters: applications, supervision trees, process names/status/queue lengths, ports (driver name, counters), ETS sizes, breakpoints. No application data | `erlang.mcp.enabled` | +| **Developer** (opt-in) | A *bounded sample of application data*: `process_state`, `mailbox_sample`, `ets_sample`; plus `allowed_ets_tables: all` and raised limits | the project's `rebar.config` names the tools in `allowed_tools` (never in the default set) | + +The developer tier is meant for local debugging of your own code: pick it deliberately, knowing that +the sampled data goes to the agent. Redaction is best effort (below), not a guarantee. + +## Controls (both tiers) + +| Control | How | +| --- | --- | +| Secure default | `erlang.mcp.enabled=false`; started only by the debug adapter for a real debug session (never LSP, `noDebug`, rebar, eunit); untrusted workspace disables it | +| Loopback only | `127.0.0.0/8` / `::1` literals, or names resolving *only* to loopback; validated in the helper VM and again in the target; wildcard/LAN/public rejected | +| Per-session credential | 32 random bytes (`crypto:strong_rand_bytes/1`) per session; bearer token required on **every** request, checked (constant time) before the body is read; never accepted from the environment or workspace files; an explicit user-level `erlang.mcp.authToken` is the only opt-in override | +| Host / Origin | `Host` must be the bound endpoint; an `Origin` is refused unless it is that endpoint; no CORS headers | +| Transport limits | POST `/mcp` only, `application/json` only, body cap before reading, header/body/connection timeouts, bounded connections, no chunked bodies, no batches, no SSE | +| Read-only tools | Fixed tools; the project policy chooses among them; unknown tools are never turned into atoms or function names. Default set: 13 metadata tools. Developer tier: 3 more, only when named | +| No execution | No eval, `rpc:call`, shell, code loading, message sending, tracing, breakpoint changes or ETS writes. The only runtime calls that touch application processes are `sys:get_state/2` (developer tier, gen_server/gen_statem/gen_event only, never plain processes, never supervisors) and `process_info/2` | +| Data minimisation (metadata tier) | No process dictionary, stack, mailbox contents, state, child start arguments, application environment, source, code paths, cookies, socket addresses, command lines; node host redacted by default; ETS: only tables approved by `allowed_ets_tables` (private tables never) | +| Developer tier data | Bounded by depth, item count and binary size; values of keys named like a secret (`password`, `token`, `secret`, `api_key`, `cookie`, `auth`...) and credentials in URLs are replaced; supervisor state (child start arguments) is never returned; a mailbox above 20000 messages is not copied; ETS sampling only for approved, non-private tables | +| Safe identifiers | Registered names and table names are resolved with `binary_to_existing_atom/2` only (and a table name is checked against the approval list *before* any atom lookup); pids must be local (`<0.N.M>`); no `binary_to_term/1` on input; validated with `\z` anchors | +| Bounded work | Per-request timeout, monitored worker processes killed on timeout (late replies dropped), bounded concurrency (excess is rejected with an MCP error), bounded collections/cursors/identity bookkeeping, bounded result size (structured result *and* its text copy). Defaults are conservative; a project may raise a limit up to a fixed ceiling (see HELP.MD), never beyond | +| Session isolation | Tokens, ids, cursors (signed with a per-session secret and bound to tool + arguments) and workers belong to one session/node; a new session invalidates everything | +| Clean shutdown | Dedicated supervisor `mcp_sup` (fail-closed: any child exit stops everything); stopped by disconnect/detach; an 8 s renewable lease renewed by the adapter through the target's own command server (independent of application processes stopped at breakpoints) stops the inspector within ~9-10 s if the adapter disappears; an attached node is never stopped by the inspector | +| Secret hand-over | The token is returned by the target only to the adapter, written by the adapter to a `0600` file in a `0700` temp directory, read and deleted at once by the extension host, and then kept in memory only. It is never in a DAP event/output, log, crash report, setting, command line or telemetry. Explicit *Copy Authorization header* is the only way it leaves VS Code | +| Logs | The *Erlang MCP* output channel journals request metadata only (method, known tool name, status, durations, sizes, counts); only lifecycle events, tool name, status and duration go to the target's logger; the server and store processes redact the token and the cursor secret in `sys:get_status` and crash reports; verbose adapter logging redacts secret-like keys and the `-setcookie` value | + +## What you can relax, and what you should not + +Reasonable on your own machine, per project: the developer tier, `allowed_ets_tables: all`, higher +limits (for example a longer `collection_ttl_ms` so `changes_since` baselines survive), and a fixed +`erlang.mcp.authToken` for a static client such as Claude Code. + +Keep: loopback binding, the bearer token, Host/Origin checks, read-only access, debug-session-only +activation. They cost nothing in daily use and are what protects you from items 2-4 above. + +## Residual risks (not removed by the controls) + +- Metadata can be sensitive: application, module, table and child-id names, process counts and + memory sizes. Enable the tools/tables you are comfortable sharing with the agent (rebar policy). +- Developer tier: the sampled state, messages and rows can contain secrets that redaction does not + recognise (a record without keys, a token in an unlabelled string, personal data). Treat them as + disclosed to the model provider. A process stopped at a breakpoint cannot be sampled (and is + reported as such); `sys:get_state` is answered by the process itself, so a very large state costs + the target time and memory (bounded by the request timeout, not preemptible). +- Node-level authorization: selecting an application is a navigation filter, not a boundary between + applications in the same VM. +- The token is bearer-only over plain HTTP on loopback: another process of the same user that can + read the debugger's memory, the extension host, or the descriptor in the few milliseconds it + exists, can use it. The descriptor's `0600` mode is not enforced on Windows (the per-user temp + directory ACL applies). +- The existing debugger command channel of the target (`vscode_connection`) is an unauthenticated + loopback HTTP endpoint (it can already evaluate expressions for the debugger). The MCP start + command travels on it, so a local process could start or stop the inspector (a local process + could already debug the node). The token is returned in the reply to the caller of that command. +- Connection slots are claimed before authentication (a bounded number, short timeouts): a local + process can keep them busy and delay the real client (denial of service, no data exposure). With + a fixed `erlang.mcp.port` on Windows the listener uses `SO_REUSEADDR`, which on that platform can + let another local process share the port; prefer the default ephemeral port there. +- Optional `erlang.mcp.authToken` (user setting only): a fixed token replaces the per-session random + one (validated 16-128 URL-safe characters). It lives in the user's settings file and, during a + session, in the adapter and target memory; it survives across sessions, so it is exposed for as long + as it is configured. Default is empty (random token). +- Distribution: attach uses the node's Erlang cookie as before. +- Not atomic and not free: collections are observation intervals; `application:which_applications/1`, + `registered/0`, `erlang:processes/0`, `ets:all/0` and `supervisor:which_children/1` materialise lists + in the target and cannot be interrupted (killing the worker only discards the late reply); + `proc_lib:initial_call/1` is used internally, only as a safety gate (to avoid calling supervisor + APIs on non-supervisors), and copies the target's process dictionary into the inspector's worker + without returning it. +- Same-machine only: no port forwarding, remote-SSH/container arrangements are unsupported. +- The MCP modules are also built into the `vscode_lsp` application's `ebin` (rebar compiles + `src/mcp`), but the LSP node never loads or starts them. diff --git a/package-lock.json b/package-lock.json index 16d5e4b1..0875b62c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "erlang", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/package.json b/package.json index 3f652cd8..7cd0e143 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "erlang", "description": "Erlang language extension for Visual Studio Code", - "version": "1.2.5", + "version": "1.2.6", "icon": "images/icon.png", "publisher": "pgourlain", "engines": { @@ -229,7 +229,17 @@ } } ], + "mcpServerDefinitionProviders": [ + { + "id": "erlang.mcp", + "label": "Erlang debug session (OTP topology inspector)" + } + ], "commands": [ + { + "command": "erlang.mcp.showConnectionDetails", + "title": "Erlang MCP: Show Connection Details" + }, { "command": "extension.rebarBuild", "title": "Erlang: rebar compile" @@ -525,6 +535,32 @@ "Always spawn through a shell", "Never spawn through a shell" ] + }, + "erlang.mcp.enabled": { + "type": "boolean", + "default": false, + "scope": "resource", + "markdownDescription": "Start a read-only, authenticated MCP server (OTP topology inspector) inside the Erlang node of a **debug session**. Off by default; never active in a normal editor session or with `noDebug`. Changes apply to new debug sessions. Tool/table allowlists and lower limits can be set with a top-level `{mcp, [...]}` term in the project's `rebar.config` (see HELP.MD)." + }, + "erlang.mcp.host": { + "type": "string", + "default": "127.0.0.1", + "scope": "resource", + "markdownDescription": "Loopback address the MCP server binds to (`127.0.0.1`, another `127.x.y.z`, `::1`, or a name that resolves only to loopback). Wildcard, LAN and public addresses are rejected." + }, + "erlang.mcp.authToken": { + "type": "string", + "default": "", + "scope": "machine", + "markdownDescription": "**Development convenience.** Fixed bearer token (16-128 characters of `A-Za-z0-9._~-`) used for every MCP session instead of a fresh random one, so a static client (e.g. `claude mcp add`) is configured once. Empty (default) = random token per session. Only read from user settings (not from the workspace). Anyone who knows the token and can reach the loopback port can read the inspector's metadata: prefer the default." + }, + "erlang.mcp.port": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 65535, + "scope": "resource", + "markdownDescription": "Port of the MCP server. `0` (default) lets the OS pick a free port, which VS Code discovers automatically. Set a fixed port for static MCP clients; if it is busy, MCP is disabled for the session (no silent rebind)." } } }, diff --git a/rebar.config b/rebar.config index aab69304..7b698cf0 100644 --- a/rebar.config +++ b/rebar.config @@ -23,6 +23,8 @@ %% Keep only the logs of the last 5 runs +%% (the MCP inspector suites live in test/mcp and run with: +%% ./rebar3 ct --dir apps/erlangbridge/test/mcp) {ct_opts, [{keep_logs, 2}]}. diff --git a/test/test-suite/mcp/mcp_configuration.test.ts b/test/test-suite/mcp/mcp_configuration.test.ts new file mode 100644 index 00000000..9a0961fb --- /dev/null +++ b/test/test-suite/mcp/mcp_configuration.test.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import { workspace, WorkspaceFolder, DebugConfiguration } from 'vscode'; +import { applyMcpSettings } from '../../../lib/ErlangConfigurationProvider'; +import { redactArgs } from '../../../lib/GenericShell'; +import { sanitizeArguments } from '../../../lib/erlangDebugSession'; +import { ErlangDebugConnection } from '../../../lib/erlangDebugConnection'; + +// erlang.mcp.* is the only activation switch of the MCP inspector; these tests cover the +// TypeScript hooks (Erlang validates authoritatively: see mcp_policy_SUITE / mcp_server_SUITE). +suite('MCP: debug configuration hook', () => { + const folder: WorkspaceFolder = workspace.workspaceFolders![0]; + + async function setMcp(key: string, value: unknown) { + await workspace.getConfiguration('erlang', folder.uri).update(key, value, false); + } + + teardown(async () => { + await setMcp('mcp.enabled', undefined); + await setMcp('mcp.host', undefined); + await setMcp('mcp.port', undefined); + }); + + test('disabled by default: no internal mcp settings reach the adapter', () => { + const cfg: DebugConfiguration = { type: 'erlang', request: 'launch', name: 'x' }; + applyMcpSettings(folder, cfg); + assert.strictEqual(cfg.mcpSettings, undefined); + assert.strictEqual(cfg.mcp, undefined); + }); + + test('enabled for the debug folder: settings are resolved for that folder', async () => { + await setMcp('mcp.enabled', true); + await setMcp('mcp.port', 4321); + const cfg: DebugConfiguration = { type: 'erlang', request: 'launch', name: 'x' }; + applyMcpSettings(folder, cfg); + assert.strictEqual(cfg.mcpSettings.enabled, true); + assert.strictEqual(cfg.mcpSettings.port, 4321); + assert.strictEqual(cfg.mcpSettings.host, '127.0.0.1'); + assert.strictEqual(cfg.mcpSettings.root, folder.uri.fsPath); + assert.strictEqual(typeof cfg.mcpSettings.trusted, 'boolean'); + }); + + test('noDebug never gets MCP settings, even when enabled', async () => { + await setMcp('mcp.enabled', true); + const cfg: DebugConfiguration = { type: 'erlang', request: 'launch', name: 'x', noDebug: true }; + applyMcpSettings(folder, cfg); + assert.strictEqual(cfg.mcpSettings, undefined); + }); + + test('a user-authored mcp block cannot enable or tune the inspector', () => { + const cfg: DebugConfiguration = { + type: 'erlang', request: 'launch', name: 'x', + mcp: { enabled: true, host: '0.0.0.0', authToken: 'secret', required: true }, + mcpSettings: { enabled: true, host: '0.0.0.0', port: 1, trusted: true, root: '/' } + }; + applyMcpSettings(folder, cfg); + assert.strictEqual(cfg.mcp, undefined); + assert.strictEqual(cfg.mcpSettings, undefined); + }); + + test('package.json declares the four settings with the documented defaults', () => { + const pkg = require('../../../../package.json'); + const props = pkg.contributes.configuration.properties; + assert.strictEqual(props['erlang.mcp.enabled'].default, false); + assert.strictEqual(props['erlang.mcp.host'].default, '127.0.0.1'); + assert.strictEqual(props['erlang.mcp.port'].default, 0); + // empty = random token per session; machine scope keeps it out of workspace settings + assert.strictEqual(props['erlang.mcp.authToken'].default, ''); + assert.strictEqual(props['erlang.mcp.authToken'].scope, 'machine'); + assert.deepStrictEqual(Object.keys(props).filter(k => k.startsWith('erlang.mcp.')).sort(), + ['erlang.mcp.authToken', 'erlang.mcp.enabled', 'erlang.mcp.host', 'erlang.mcp.port']); + // no `required` option + assert.ok(!('erlang.mcp.required' in props)); + assert.ok(pkg.contributes.commands.some((c: any) => c.command === 'erlang.mcp.showConnectionDetails')); + }); +}); + +suite('MCP: secrets never reach logs', () => { + test('verbose launch/attach argument logging redacts secret-like keys', () => { + const out = sanitizeArguments({ node: 'a@b', cookie: 'SENTINEL_COOKIE', authToken: 'SENTINEL_TOKEN', cwd: '/x', password: 'p' }); + assert.deepStrictEqual(out, { node: 'a@b', cookie: '', authToken: '', cwd: '/x', password: '' }); + assert.ok(!JSON.stringify(out).includes('SENTINEL')); + }); + + test('the command line log redacts the Erlang cookie', () => { + const out = redactArgs(['-noshell', '-setcookie', 'SENTINEL_COOKIE', '-s', 'x']); + assert.deepStrictEqual(out, ['-noshell', '-setcookie', '', '-s', 'x']); + }); + + test('the debugger bridge compiles the MCP inspector modules, all prefixed mcp_', () => { + const files: string[] = (ErlangDebugConnection.prototype).get_ErlangFiles.call({}); + const mcp = files.filter(f => f.startsWith('mcp/')); + assert.strictEqual(mcp.length, 8); + assert.ok(mcp.every(f => /^mcp\/mcp_[a-z_]+\.erl$/.test(f))); + }); +}); diff --git a/test/test-suite/mcp/mcp_debug_adapter.test.ts b/test/test-suite/mcp/mcp_debug_adapter.test.ts new file mode 100644 index 00000000..779f6bf9 --- /dev/null +++ b/test/test-suite/mcp/mcp_debug_adapter.test.ts @@ -0,0 +1,300 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import { ChildProcess, spawn } from 'child_process'; + +// End-to-end: the real debug adapter (out/lib/erlangDebug.js) over DAP/stdio launches a real +// `erl` target. Covers the enablement gate, the secret hand-over, the rebar.config policy, fixed +// port conflicts, lease renewal and cleanup. Requires `erl` on the PATH (as the other debugger tests). +const repoRoot = path.join(__dirname, '..', '..', '..', '..'); + +class DapClient { + private buf = Buffer.alloc(0); + private seq = 1; + private listeners: Array<(m: any) => void> = []; + readonly events: any[] = []; + readonly child: ChildProcess; + + constructor() { + this.child = spawn(process.execPath, [path.join(repoRoot, 'out', 'lib', 'erlangDebug.js')], + { stdio: 'pipe', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }); + this.child.stdout!.on('data', d => this.onData(d)); + } + + private onData(d: Buffer) { + this.buf = Buffer.concat([this.buf, d]); + for (;;) { + const end = this.buf.indexOf('\r\n\r\n'); + if (end < 0) { return; } + const len = parseInt(/Content-Length: (\d+)/.exec(this.buf.slice(0, end).toString())![1]); + if (this.buf.length < end + 4 + len) { return; } + const msg = JSON.parse(this.buf.slice(end + 4, end + 4 + len).toString()); + this.buf = this.buf.slice(end + 4 + len); + if (msg.type === 'event') { this.events.push(msg); } + this.listeners.slice().forEach(l => l(msg)); + } + } + + request(command: string, args: any): Promise { + const seq = this.seq++; + const body = JSON.stringify({ seq, type: 'request', command, arguments: args }); + this.child.stdin!.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + return new Promise(resolve => { + const l = (m: any) => { + if (m.type === 'response' && m.request_seq === seq) { + this.listeners.splice(this.listeners.indexOf(l), 1); + resolve(m); + } + }; + this.listeners.push(l); + }); + } + + waitEvent(name: string, pred: (b: any) => boolean = () => true, timeout = 40000): Promise { + const found = this.events.find(e => e.event === name && pred(e.body)); + if (found) { return Promise.resolve(found.body); } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout waiting for '${name}'`)), timeout); + const l = (m: any) => { + if (m.type === 'event' && m.event === name && pred(m.body)) { + clearTimeout(timer); + this.listeners.splice(this.listeners.indexOf(l), 1); + resolve(m.body); + } + }; + this.listeners.push(l); + }); + } + + async attach(project: string, extra: any): Promise { + const init = await this.request('initialize', { adapterID: 'erlang', pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true }); + assert.ok(init.success, init.message); + const attach = this.request('attach', { cwd: project, verbose: false, useShell: false, erlangPath: '', ...extra }); + await this.waitEvent('initialized'); + assert.ok((await attach).success); + assert.ok((await this.request('configurationDone', {})).success); + } + + async launch(project: string, extra: any): Promise { + const init = await this.request('initialize', { adapterID: 'erlang', pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true }); + assert.ok(init.success, init.message); + const launch = this.request('launch', { cwd: project, verbose: false, useShell: false, erlangPath: '', ...extra }); + await this.waitEvent('initialized'); + assert.ok((await launch).success); + assert.ok((await this.request('configurationDone', {})).success); + } + + async close(): Promise { + await this.request('disconnect', { terminateDebuggee: true }).catch(() => undefined); + await new Promise(r => setTimeout(r, 500)); + this.child.kill(); + } +} + +function post(url: string, token: string, body: any): Promise<{ status: number, json: any }> { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = http.request({ host: u.hostname, port: u.port, path: u.pathname, method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token } }, res => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode!, json: data ? JSON.parse(data) : undefined })); + }); + req.on('error', reject); + req.end(JSON.stringify(body)); + }); +} + +function refused(url: string): Promise { + const u = new URL(url); + return new Promise(resolve => { + const s = net.connect(Number(u.port), u.hostname); + s.on('connect', () => { s.destroy(); resolve(false); }); + s.on('error', () => resolve(true)); + }); +} + +const call = (url: string, token: string, name: string, args: any = {}) => + post(url, token, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + +suite('MCP: debug adapter end-to-end (real erl target)', function () { + this.timeout(90000); + let project: string; + let client: DapClient; + let attachTarget: ChildProcess | undefined; + + setup(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-e2e-')); + }); + + teardown(async () => { + attachTarget?.kill(); + attachTarget = undefined; + await client?.close(); + fs.rmSync(project, { recursive: true, force: true }); + }); + + const settings = (over: any = {}) => ({ mcpSettings: { enabled: true, host: '127.0.0.1', port: 0, trusted: true, root: project, ...over } }); + + test('enabled: endpoint announced without the secret, policy honoured, lease renewed, cleaned up', async () => { + fs.writeFileSync(path.join(project, 'rebar.config'), + '{mcp, [{allowed_tools, [<<"runtime_summary">>, <<"debug_session">>]}]}.\n'); + client = new DapClient(); + await client.launch(project, settings()); + const started = await client.waitEvent('erlangMcp', b => b.status !== undefined); + assert.strictEqual(started.status, 'started', JSON.stringify(started)); + assert.match(started.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + + // the credential is in a 0600 descriptor, never in a DAP event or output + const descriptor = JSON.parse(fs.readFileSync(started.descriptor, 'utf8')); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(started.descriptor).mode & 0o777, 0o600); + } + assert.ok(descriptor.token.length >= 43); + assert.ok(!JSON.stringify(client.events).includes(descriptor.token), 'token leaked in a DAP message'); + + assert.strictEqual((await post(started.url, 'wrong-token', { jsonrpc: '2.0', id: 1, method: 'ping' })).status, 401); + const list = await post(started.url, descriptor.token, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + assert.deepStrictEqual(list.json.result.tools.map((t: any) => t.name), ['runtime_summary', 'debug_session']); + const dbg = await call(started.url, descriptor.token, 'debug_session'); + assert.strictEqual(dbg.json.result.structuredContent.mode, 'launch'); + assert.strictEqual((await call(started.url, descriptor.token, 'process_info', { name: 'init' })).json.error.code, -32602); + + // the adapter renews the lease (target lease: 8 s) + await new Promise(r => setTimeout(r, 10000)); + assert.strictEqual((await post(started.url, descriptor.token, { jsonrpc: '2.0', id: 1, method: 'ping' })).status, 200); + + // request journal: one erlangMcpCall event per request, metadata only + const journal = await client.waitEvent('erlangMcpCall', b => b.method === 'tools/call' && b.tool === 'debug_session'); + assert.strictEqual(journal.status, 'ok'); + assert.strictEqual(typeof journal.durationMs, 'number'); + assert.ok(!JSON.stringify(client.events.filter(e => e.event === 'erlangMcpCall')).includes(descriptor.token)); + + const dir = path.dirname(started.descriptor); + await client.request('disconnect', { terminateDebuggee: true }); + const stopped = await client.waitEvent('erlangMcp', b => b.status === 'stopped', 15000); + assert.ok(stopped.calls >= 4, JSON.stringify(stopped)); + assert.strictEqual(typeof stopped.serverMs, 'number'); + assert.ok(!fs.existsSync(dir), 'descriptor directory must be removed'); + await new Promise(r => setTimeout(r, 1000)); + assert.ok(await refused(started.url), 'endpoint must be closed'); + }); + + test('all tools and the prompt answer in a real target (default policy)', async () => { + client = new DapClient(); + await client.launch(project, settings()); + const started = await client.waitEvent('erlangMcp', b => b.status !== undefined); + assert.strictEqual(started.status, 'started', JSON.stringify(started)); + const token = JSON.parse(fs.readFileSync(started.descriptor, 'utf8')).token; + const tools = (await post(started.url, token, { jsonrpc: '2.0', id: 1, method: 'tools/list' })).json.result.tools.map((t: any) => t.name); + assert.strictEqual(tools.length, 13, tools.join(',')); + const ok = async (name: string, args: any = {}) => { + const r = await call(started.url, token, name, args); + assert.ok(r.json.result && r.json.result.isError === false, name + ': ' + JSON.stringify(r.json)); + return r.json.result.structuredContent; + }; + const rt = await ok('runtime_summary'); + assert.ok(rt.resources.processes.count > 0 && rt.resources.atoms.limit > 0); + const top = await ok('top_processes', { limit: 3, format: 'mermaid' }); + assert.strictEqual(top.entities.length, 3); + assert.ok(top.mermaid.startsWith('graph TD')); + assert.ok((await ok('top_ports', { limit: 3 })).entities.length > 0); + // no table is approved by default: nothing is counted, and that is reported + const ets = await ok('ets_summary', { limit: 3 }); + assert.strictEqual(ets.entities.length, 0); + assert.ok(ets.omissions.some((o: any) => o.reason === 'policy_denied')); + const denied = await call(started.url, token, 'process_state', { name: 'init' }); + assert.strictEqual(denied.json.error.code, -32602, 'developer tools are off by default'); + const topo = await ok('topology_overview', { name: 'kernel', format: 'mermaid' }); + assert.ok(topo.relationships.some((r: any) => r.type === 'supervises'), 'kernel supervision tree'); + assert.ok(topo.mermaid.includes('-->')); + const changes = await ok('changes_since', { collectionId: topo.collectionId }); + assert.strictEqual(changes.scope.baselineTool, 'topology_overview'); + const prompt = await post(started.url, token, { jsonrpc: '2.0', id: 1, method: 'prompts/get', + params: { name: 'map_application', arguments: { application: 'kernel' } } }); + assert.ok(prompt.json.result.messages[0].content.text.includes('kernel')); + assert.ok(!JSON.stringify(client.events).includes(token), 'token leaked in a DAP message'); + await client.request('disconnect', { terminateDebuggee: true }); + }); + + test('disabled (default): no listener, no event, debugging works', async () => { + client = new DapClient(); + await client.launch(project, {}); + await new Promise(r => setTimeout(r, 6000)); + assert.strictEqual(client.events.filter(e => e.event === 'erlangMcp').length, 0); + }); + + test('noDebug never starts the inspector, even when enabled', async () => { + client = new DapClient(); + await client.launch(project, { noDebug: true, ...settings() }); + await new Promise(r => setTimeout(r, 6000)); + assert.strictEqual(client.events.filter(e => e.event === 'erlangMcp').length, 0); + }); + + test('untrusted workspace disables only MCP', async () => { + client = new DapClient(); + await client.launch(project, settings({ trusted: false })); + const err = await client.waitEvent('erlangMcp', b => b.status === 'error', 60000); + assert.match(err.message, /not trusted/); + }); + + test('non-loopback host disables only MCP', async () => { + client = new DapClient(); + await client.launch(project, settings({ host: '0.0.0.0' })); + const err = await client.waitEvent('erlangMcp', b => b.status === 'error', 60000); + assert.match(err.message, /loopback/); + }); + + test('malformed or profile-only rebar.config policy disables MCP with an actionable error', async () => { + fs.writeFileSync(path.join(project, 'rebar.config'), '{mcp, [{allowed_tools, [}.\n'); + client = new DapClient(); + await client.launch(project, settings()); + const err = await client.waitEvent('erlangMcp', b => b.status === 'error'); + assert.match(err.message, /malformed/); + assert.ok(!/token|Bearer/i.test(err.message)); + }); + + test('attach: the inspector runs in the attached node, and a detach leaves that node running', async function () { + const nodeName = `mcp_e2e_${process.pid}`; + const target = spawn('erl', ['-noshell', '-sname', nodeName, '-eval', 'receive stop -> ok end'], { stdio: 'ignore' }); + attachTarget = target; + try { + await new Promise(r => setTimeout(r, 2500)); + const host = os.hostname().split('.')[0]; + client = new DapClient(); + await client.attach(project, { node: `${nodeName}@${host}`, ...settings() }); + const started = await client.waitEvent('erlangMcp', b => b.status !== undefined, 60000); + assert.strictEqual(started.status, 'started', JSON.stringify(started)); + const descriptor = JSON.parse(fs.readFileSync(started.descriptor, 'utf8')); + const summary = await call(started.url, descriptor.token, 'runtime_summary', { redactNodeHost: false }); + assert.strictEqual(summary.json.result.structuredContent.node, `${nodeName}@${host}`); + const dbg = await call(started.url, descriptor.token, 'debug_session'); + assert.strictEqual(dbg.json.result.structuredContent.mode, 'attach'); + + await client.request('disconnect', { terminateDebuggee: false }); + await client.waitEvent('erlangMcp', b => b.status === 'stopped', 15000); + await new Promise(r => setTimeout(r, 1500)); + assert.ok(await refused(started.url), 'endpoint must be closed after the detach'); + assert.strictEqual(target.exitCode, null, 'the attached node must keep running'); + } finally { + target.kill(); + } + }); + + test('fixed port already in use: MCP disabled, no rebinding', async () => { + const busy = net.createServer(); + await new Promise(r => busy.listen(0, '127.0.0.1', () => r())); + const port = (busy.address() as net.AddressInfo).port; + try { + client = new DapClient(); + await client.launch(project, settings({ port })); + const err = await client.waitEvent('erlangMcp', b => b.status === 'error'); + assert.match(err.message, /already in use/); + } finally { + busy.close(); + } + }); +});