# MCP Server Specification

**Status:** v1.77 (frozen contract — additive-only changes after this point)
**Owner:** datapipelines.co core
**Depends on:** [Type System spec](/docs/type-system.md), [Pipeline Contract spec](/docs/pipeline-contract.md), [REST API spec](/docs/rest-api.md), [Auth spec](/docs/auth.md), [Templates spec](/docs/templates.md)
**Last updated:** 2026-10-25

---

## 1. Purpose

datapipelines.co is **MCP-native**. Agentic tools (Claude Desktop, GLM, Copilot, custom LangChain/LlamaIndex agents, etc.) connect to a datapipelines.co instance via the [Model Context Protocol](https://modelcontextprotocol.io) to discover pipelines, execute them, and read results — without writing custom integration code.

This spec defines:
- The **MCP transport** (Streamable HTTP) and how clients connect.
- The **authentication model** (the user's MCP key — created on the Keys page with a role, one credential per agent).
- The **tool surface** (functions the agent can call).
- The **resource surface** (entities the agent can read as files).
- The **prompt surface** (predefined workflows the agent can invoke).
- The **error model** (how datapipelines errors map to MCP errors).

---

## 2. Design Principles

1. **MCP is a thin adapter over REST.** Every MCP tool maps to one or more REST endpoints defined in [REST API spec](/docs/rest-api.md). No business logic in the MCP layer — it's translation only.
2. **Tools for actions, resources for inspection.** If an agent needs to *do* something (execute a pipeline, create a template), it calls a tool. If it needs to *read* something (look at a pipeline definition), it reads a resource. We avoid duplicating read operations as both.
3. **API key, not OAuth.** Self-hosted, internal-users-only deployment model makes OAuth overkill. The user creates an MCP key on the Keys page with the role the agent should have (keys v2, [Auth §7.4](/docs/auth.md#74-issuance)), passes it to their agent, the agent uses it — in either the `DP-API-Key` header or an `Authorization: Bearer dpk_...` header. See [Auth §8.5](/docs/auth.md#85-mcp-endpoint-mcp).
4. **MCP versioning follows the protocol.** We commit to a specific MCP protocol version per datapipelines.co release, and document upgrade paths when the protocol evolves.
5. **Fail loudly, never silently.** MCP-level errors (transport, auth) and application errors (pipeline validation, datasource unreachable) both surface as structured errors the agent can act on. No silent fallbacks.
6. **Workspace-scoped by the key (workspaces design §5.2/§9).** Every tool and resource operates inside the workspace the API key is PINNED to at issuance — `DP-Workspace` is refused on MCP requests (`400 workspace.header_forbidden`), because a header-switchable agent key would make every leaked key a skeleton key across the user's workspaces. Pipelines, templates and executions of other workspaces are ABSENT (not hidden): their ids resolve as not-found. Datasources visible here are exactly the ones GRANTED to the pinned workspace (D-R7): there is no global datasource, and one that is not granted is ABSENT, not hidden. The `initialize` result's `instructions` field states this so an agent does not reason about invisible siblings.

---

## 3. Transport

### 3.1 Transport choice: Streamable HTTP

We expose MCP via the protocol's **Streamable HTTP** transport:
- Single endpoint: `POST /mcp` (and `GET /mcp` for server-to-client notifications/SSE).
- Content types: `application/json` for single requests/responses, `text/event-stream` for streamed responses.
- Works through standard HTTP infrastructure (proxies, load balancers, TLS terminators).
- No WebSocket requirement (which would need custom proxy config).

This is the protocol's network-native transport, appropriate for our self-hosted, network-resident deployment model. The stdio transport (used for local-tools) is not supported — our product is a server, not a local process.

> **Implementation gate — RESOLVED at P6b (2026-08-10).** This spec is authored against the durable shape of MCP; the concrete protocol version string is a build-time input, not a frozen contract term. The checklist below was completed against the official specification and the shipped MCP SDK when the module was implemented:
>
> - [x] **Current protocol version string.** Pinned to `2025-06-18`, returned in `initialize.protocolVersion` (§5.1) and accepted in the `MCP-Protocol-Version` header (§3.2); a `PinnedTransport` decorator advertises it as the sole supported version.
> - [x] **Version-negotiation rule.** The server negotiates **down** to its pinned version — a client offering a newer version is served `2025-06-18` (verified in-process).
> - [x] **Streamable HTTP requirements.** The v1 server is **stateless**: `POST /mcp` accepts `application/json` + `text/event-stream`; `GET /mcp` for a server-initiated SSE stream is **optional and NOT served** (answered `405`) — so there are no server-to-client notifications in v1 (§5.1, §10), no `MCP-Session-Id` issuance, and no resumability headers. A stateful transport (session ids, the notification stream) is a v2 item ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)).
> - [x] **SDK coordinates.** `io.modelcontextprotocol:mcp-core` + `mcp-json-jackson2`, both `mcp-sdk = 2.0.0`, pinned in the version catalog ([Module Structure §8](/docs/module-structure.md)).
>
> These resolutions were additive corrections to §3 and §5.1 only — the tool, resource, and prompt surfaces did not depend on them.

### 3.2 Endpoint structure

```
POST {host}/mcp
Headers:
  Content-Type: application/json
  Accept: application/json, text/event-stream
  DP-API-Key: dpk_<id>.<secret>       # OR: Authorization: Bearer dpk_<id>.<secret>
  MCP-Protocol-Version: 2025-06-18    # placeholder — pinned by the §3.1 implementation gate
  MCP-Session-Id: {session-uuid}      # optional; server may issue for stateful sessions
```

Exactly one credential carrier is required, and the two are equivalent: `DP-API-Key` (the REST convention, [REST API §3.6](/docs/rest-api.md#36-custom-header-registry)) or `Authorization: Bearer dpk_...` for MCP clients that can only set the standard Authorization header. Both route through the identical API-key validation path — see §4.1 and [Auth §8.5](/docs/auth.md#85-mcp-endpoint-mcp). Session JWTs (`dp_session` cookie, or a Bearer token that is not a `dpk_` key) are **not** accepted on `/mcp`.

Server response: JSON for single-message exchanges, `text/event-stream` for streamed responses.

**The request-body cap reaches `/mcp` first** (#279, pipeline-contract §13.21): the platform's cap filter is a servlet filter registered ahead of the transport servlet, so a body over `datapipelines.web.max-request-bytes` ([Configuration §3.31](/docs/configuration.md#331-web-request-limits)) is refused with the same `413 request.body_too_large` envelope REST answers — the MCP SDK reads the whole body before its dispatcher parses anything, so the filter is the surface's only cap. A declared `Content-Length` over the cap is refused unread; a chunked over-cap body is cut off mid-read by the counting stream (the SDK answers its own JSON-RPC error there — the body never parses, which is the cap's contract). The transport's JSON mapper carries the same stated Jackson `StreamReadConstraints` (nesting 100, string 4M chars, number 1000 digits) as the REST surface's, built from the same factory (`RequestLimits.jsonFactory`): a body the transport cannot read — malformed, or nested past 100 — is refused `400` with the SDK's JSON-RPC error (`-32600`, "Invalid message format"). **Since #291 that error carries no exception internals**: the SDK writes its `McpError` exception through the transport's mapper, and before #291 the body was the serialized throwable — `stackTrace` (every frame's class, file and line), `cause`, `suppressed`; the mapper now drops them for every throwable, leaving `jsonRpcError` and `message`. A chunked body over the cap is cut at the cap as well, but the SDK's transport catches the refusal inside its read loop and answers its own JSON-RPC error (HTTP 500), not the 413 envelope — a body with a declared `Content-Length`, every standard client's, gets the envelope.

### 3.3 Session lifecycle

- **Stateless by default.** Each request carries full auth context. Server does not require session continuity.
- **Optional session.** Server MAY issue an `MCP-Session-Id` for clients that want one. Session state = nothing important (cached auth, nothing else).

---

## 4. Authentication

### 4.1 Auth model

`/mcp` is **API-key-only**. Every MCP request must carry a datapipelines API key in one of two equivalent headers:

- `DP-API-Key: dpk_<id>.<secret>` — the REST convention; the primary case.
- `Authorization: Bearer dpk_<id>.<secret>` — for MCP clients that can only set the standard Authorization header (Claude Desktop and several others). The filter recognizes the `dpk_` prefix and routes the token through the identical validation path.

Both are validated by [Auth §7.3](/docs/auth.md#73-validation-flow) — same lookup, same Argon2id verification, same 60s-TTL revocation/liveness re-check ([Auth §11.4](/docs/auth.md#114-api-key-validation-cache)). Revocation or deactivation of the key's identity, its MCP creator or its pinned workspace takes effect within the auth cache TTL (60 s by default, #215 B5). The creator's deactivation remains a liveness check (A20); a later change to the creator's member role changes nothing for the key, whose authority is its own chosen role (keys v2, [Auth §7.5](/docs/auth.md#75-key-roles)).

**Session JWTs are not accepted on `/mcp`.** There is no cookie auth and no non-`dpk_` Bearer token path — a browser-embedded MCP client must use an API key like any other agent.

API keys of the `mcp` kind (keys v2 #233, A13–A19) are:
- **Created on the Keys page by anyone holding `mcp_key.create`** (author, promoter, workspace admin) — never at sign-in any more: the login mint is retired (A15), and `mcp_key.create`'s SUBSET RULE (A14) decides which ROLES the creator may give the key (never `viewer`, never a super-admin role — B1). Rotation is create-a-new-one-and-revoke-the-old (`mcp_key.revoke_own`).
- Expiring or not, creator-scoped to revoke; HTTP surface in [REST API §16.1](/docs/rest-api.md#161-api-keys-creation-is-on-the-keys-page--keys-v2).
- Acting as their OWN identity, which holds the CHOSEN member role (`author` | `promoter` | `workspace_admin`) in the key's workspace ([Auth §7.5](/docs/auth.md#75-key-roles), A13): nothing is derived from a membership at request time and nothing is capped — the role IS the key's authority, and **no key is ever a super admin** (B1), so no key reaches an instance verb.

**The MCP key connects an MCP client to `/mcp` and nothing else** (#215 B2, owner ruling 2026-09-24: "MCP key should be only MCP"): presented on any REST route, page or partial it is refused with `403 endpoint.key_kind_refused`, `details.reason = "mcp_key_off_surface"` — a program that calls REST uses a person's session, and a program that calls published endpoints uses an `endpoint` key. The other two kinds ([Auth §7.7](/docs/auth.md#77-key-kinds-and-published-endpoint-bindings)) do not reach `/mcp` at all — an `endpoint` key authorises published endpoints and a `server` key the promotion routes, and each is refused here with `403 endpoint.key_kind_refused` by `McpAuthFilter` (the interceptor never sees `/mcp`, which is a servlet, so the refusal is made again at the transport). Such a key could otherwise read the whole tool catalogue through `tools/list` without being able to call any of it.

**Enforcement is the ROLE** ([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative), [§11A](/docs/auth.md#11a-roles)). Each tool declares ONE catalog permission on its catalog entry (`McpToolCatalog.Entry.permission`) — this spec restates it in §6.2 for readability, but the catalog is authoritative on any conflict, and one function (`ScopeMatrix.allowedTool`) answers here and at the REST interceptor. A tool that declares none is refused (`auth.permission.undeclared`).

**A key acts as its OWN identity, at the role chosen at creation** (keys v2, [Auth §7.5](/docs/auth.md#75-key-roles)): every request judges the key's OWN role against the tool's permission — nothing is derived from a membership at request time, so a later change to the creator's member role changes nothing for the key. What DOES reach the key within the 60 s validation-cache TTL is revocation and deactivation (§4.2): a revoked key, a deactivated key identity or MCP creator, or a deactivated workspace stops working within about a minute. A refusal is `auth.role_required` ([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)) — `details.required` names the permission and `details.held` the key's own role; the fix is a key whose role holds the permission (create one under the subset rule, revoke this one), never a member-role change.

**A promoter's key sees through the promoter LENS (178, [Auth §11A.1](/docs/auth.md#11a1-the-404-rule)).** The role narrows what a READ returns, never who may read: `pipelines_list/get`, `templates_list/get/used_by`, `endpoints_list/get`, the resource catalogue and the pipeline/template resources answer a promoter with RELEASED objects newer than the promotion target's inventory entry ([Versioning §10.2](/docs/versioning.md#102-the-listing-rule-what-the-ui-shows)) and nothing else — a draft, an already-promoted object, or a pipeline behind the target is ABSENT for that key and resolves as not-found, exactly as another workspace's would; a visible object's pending DRAFT is invisible too — `pipelines_get` / `templates_get` default to the RELEASE for that key, carry no `draft` pointer, and an explicit draft `version` is not-found. The inventory is read through a per-workspace cache (`datapipelines.deployment.promotion.inventory-cache-ttl-seconds`); when the target cannot be read the lens fails CLOSED — lists are empty, gets are not-found, never a 502 to a read — and the server logs `pipeline.promotion.lens_unavailable` once per window. Every other role's key is unaffected and triggers no target call.

A key's role can be `promoter` or `workspace_admin`, and such a key is limited by its ROLE — the permission catalog rows it holds and, for a `promoter`, the release lens above — never by a cap. Release, promote, workspace creation and membership have no MCP tool at all: they are human verbs, which is the same reason no key is ever a super admin. Registering, editing and deleting datasources remain UI/REST-only.

**Datasource visibility is a GRANT** ([Auth §11A](/docs/auth.md#11a-roles)). A datasource not granted to the key's pinned workspace does not exist for it: `datasources_list` is the truth, and guessing a name gets the not-found envelope, never a "forbidden".

**Security chain.** `/mcp` (both `POST` and `GET`) is an explicit matcher in the Spring Security filter chain: CSRF-exempt (no cookie auth to forge against), no session cookies accepted, the same permission enforcement as REST, same per-user rate limits ([REST API §12](/docs/rest-api.md#12-rate-limiting)). See [Auth §8.5](/docs/auth.md#85-mcp-endpoint-mcp).

**The principal travels WITH the call, never on the thread (134).** `McpAuthFilter` resolves the key on the servlet thread and hands the principal to the MCP layer through the transport context (`McpToolContext`); the SDK then runs every tool handler on its own scheduler thread (`boundedElastic`), where Spring Security's thread-local context is **empty**. So a tool — and anything a tool calls — must take *who is asking* and *which workspace* as arguments from `McpToolContext`; a domain port or `@Bean` adapter that reads `SecurityContextHolder` answers correctly over REST and as "no principal" over MCP, with no error anywhere. The save-time datasource lookup was exactly that: a workspace-owned datasource validated as `pipeline.validation.unknown_datasource` over MCP while the same body was `201` over REST (measured 2026-09-14; the demo datasources are owner-less + granted, which is why the acceptance run never saw it). Since 134 the validator's datasource port takes the workspace explicitly, as the executor always did; `McpSaveWorkspaceDatasourceE2eTest` holds the line.

### 4.2 Unauthorized behavior

Missing credential (no `DP-API-Key` header and no Bearer `dpk_` token):
- HTTP `401 Unauthorized` with JSON body:
  ```json
  {"error": {"code": "auth.api_key.missing", "message": "..."}}
  ```
- MCP session is not established.

Invalid, revoked or expired key:
- HTTP `401 Unauthorized` with `auth.api_key.invalid` (or `auth.api_key.expired`).

Deactivated identity or deactivated pinned workspace (180, [Auth §11A.3](/docs/auth.md#11a3-deactivation)) — judged at validation, so no MCP session is established and even `tools/list` is refused:
- HTTP `401 Unauthorized` with `auth.principal_deactivated` (the key's IDENTITY is deactivated — with its key, keys v2 A17; reactivation restores the key);
- HTTP `404 Not Found` with `auth.key_workspace_inactive` (the WORKSPACE the key is pinned to is deactivated).

A role that does not hold the tool's permission (e.g., an `author`-role key calling nothing beyond itself — or a `promoter`-role key calling `pipelines_create`):
- A tool result with `isError: true` carrying `auth.role_required` (§9.2; keys v2 A13 — the key is judged by its OWN role, so the issuer-demotion code is gone), `details.required` naming the permission and `details.held` the key's role — the call was dispatched and refused; nothing ran. The MCP ownership rules (`templates_purge_draft` on another person's draft, `executions_cancel` on a run another credential started) answer `auth.role_required` with `details.reason`.

Codes follow the `{domain}.{entity}.{failure}` convention; the registry of record is [Pipeline Contract §13.7](/docs/pipeline-contract.md#137-authentication--authorization).

### 4.3 Why not OAuth

OAuth adds:
- Authorization server (to build/maintain)
- Redirect flows (impossible for non-browser agents like Claude Desktop)
- Token refresh logic (per agent)
- Client registration (per agent)

For self-hosted, internal-users-only deployment, API keys are simpler and sufficient. Future multi-tenant SaaS deployment would revisit this.

---

## 5. Server Metadata & Capabilities

### 5.1 `initialize` response

```json
{
  "protocolVersion": "2025-06-18",
  "serverInfo": {
    "name": "datapipelines",
    "version": "1.0.0"
  },
  "capabilities": {
    "tools": {"listChanged": false},
    "resources": {"listChanged": false, "subscribe": false},
    "prompts": {"listChanged": false}
  },
  "instructions": "This server is workspace-scoped: every tool and resource operates inside the workspace the API key is pinned to. ..."
}
```

- `instructions` (workspaces design §9) states the workspace context every agent reads first: content in other workspaces is absent (not hidden) — it resolves as not-found — and the key sees exactly the datasources granted to its workspace (D-R7). What follows is ranked for a client that truncates it (#241): where the manual lives, then the draft rule, the name grammar, "humans register datasources" and the three recoveries — all inside 1,843 characters, 10 % under the 2,048-character cap Claude Code applies by default. The full text is §15's Delivery 1 block and ships as `McpServerFactory.SERVER_INSTRUCTIONS`.

- `tools.listChanged: false` — the tool surface is **static**: the same 61 tools (§6.1) for every caller, for the lifetime of the server. Advertising `true` would promise `notifications/tools/list_changed` messages the v1 server never sends. Dynamic per-pipeline tools (`pipeline_execute_{name}`, which would make the list genuinely mutable) are a v2 item — [ROADMAP §3.7](/docs/roadmap.md#37-mcp-server). When they land, this flips to `true` together with the notification implementation.
- `resources.listChanged: false` — the *set of resource URIs* does change as pipelines and executions are created, but the v1 server sends no change notifications; clients re-fetch `resources/list` (§7.3) when they need a current view.
- `resources.subscribe: false` — no live subscriptions in v1. Clients re-fetch resources as needed.
- `prompts.listChanged: false` — the prompt surface (§8) is static in v1.
- **No `logging` capability in v1.** The v1 transport is stateless (§3.3): it answers `GET /mcp` with `405`, so there is no server-to-client stream to carry `notifications/message`. Advertising `logging` would promise notifications no client can receive — the same reasoning as `listChanged: false`. Live progress during a blocking `pipelines_execute` (§6.2.3) is therefore not available in v1; the authoritative per-node record is the `node_stats` array in the tool's final result. Logging/progress notifications return with the stateful transport in v2 ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)).

`protocolVersion` is the placeholder pending the §3.1 implementation-gate check. `serverInfo.version` is the datapipelines.co release version.

---

## 6. Tool Surface

### 6.1 Tool naming convention

Tools are named `{domain}_{action}`:

- `pipelines_list`
- `pipelines_get`
- `pipelines_execute`
- `pipelines_execute_node`
- `pipelines_create`
- `pipelines_update`
- `templates_list`
- `templates_get`
- `templates_used_by`
- `templates_create`
- `templates_update`
- `templates_render`
- `templates_evaluate`
- `templates_purge_draft`
- `datasources_list`
- `datasources_get`
- `datasources_test`
- `datasources_get_schemas`
- `datasources_get_tables`
- `datasources_get_columns`
- `datasources_get_table_stats`
- `datasources_preview_rows`
- `sql_probe`
- `executions_list`
- `executions_get`
- `executions_get_result`
- `executions_cancel`
- `calculators_list`
- `calculators_get`
- `endpoints_create`
- `endpoints_list`
- `endpoints_get`
- `endpoints_delete`
- `lake_tables_register`
- `lake_tables_import`
- `lake_tables_unregister`
- `semantics_record`
- `semantics_list`
- `semantics_retire`
- `docs_list`
- `docs_get`
- `pipelines_run_checks`
- `parameter_sets_list`
- `parameter_sets_get`
- `parameter_sets_create`
- `parameter_sets_update`
- `parameter_sets_evaluate`
- `parameter_sets_purge_draft`
- `visualizations_list`
- `visualizations_get`
- `visualizations_create`
- `visualizations_update`
- `visualizations_purge_draft`
- `visualizations_test_start`
- `visualizations_test_submit`
- `dashboards_list`
- `dashboards_get`
- `dashboards_create`
- `dashboards_update`
- `dashboards_purge_draft`
- `dashboards_validate`

A future enhancement: dynamically-generated per-pipeline tools (e.g., `pipeline_execute_monthly_revenue_report`) for pipelines the user wants to expose as named tools to agents. Marked for v2 ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)) — this is why `tools.listChanged` is `false` in v1 (§5.1).

### 6.2 Tool definitions

Every tool definition below carries a **Permission** row: the catalog permission the tool declares (`McpToolCatalog.Entry.permission`), which the calling key's ROLE must hold — the role chosen at creation ([Auth §7.5](/docs/auth.md#75-key-roles)). Those values are sourced from the [Auth §7.6 permission catalog](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative), which is authoritative — if this doc and the catalog ever disagree, the catalog wins. Scopes were removed (#215): the role is the one axis, so a `promoter`- or `workspace_admin`-role key reaches farther than an `author`-role one — exactly as far as its catalog rows allow, and no farther.

Every tool's result envelope, including its error shape, is §6.3.

#### 6.2.1 `pipelines_list`

List pipelines the caller has access to.

```json
{
  "name": "pipelines_list",
  "description": "List the pipelines of the key's pinned workspace, filtered by owner, datasource, or text search. Returns metadata (id, name, display_name, description, version, status, updated_at) — version is the WORKING version and status says DRAFT or RELEASED, so an unreleased pipeline is visible as such. Not the full body. Use pipelines_get for the body; pipelines in other workspaces are absent from this listing and resolve as not-found by id. Pipeline names are FOLDER PATHS (finance/payments/daily_settlement): pass prefix to BROWSE one level of that tree — prefix:\"\" lists the roots, prefix:\"finance\" lists what is directly under finance — and q to SEARCH across full paths. Start with prefix:\"\" to see which roots this workspace already uses before creating a pipeline under a new one. A promoter's key sees only RELEASED pipelines newer than the promotion target's (the promoter lens); every other pipeline is absent for it and resolves as not-found by id.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "owner": {"type": "string", "description": "Filter by owner user ID."},
      "datasource": {"type": "string", "description": "Filter by datasource name."},
      "q": {"type": "string", "description": "Case-insensitive substring search over name, display name and description (the whole path counts as the name); ignored when prefix is present — pass prefix to browse, q to search. At most 200 characters after trimming; a longer q is refused as invalid params."},
      "prefix": {"type": "string", "description": "Browse ONE level of the folder tree instead of listing flat: returns that prefix's direct sub-folders (with counts) and its direct children. An empty string is the root. Use this to discover which roots and folders exist; use q to search across full paths."},
      "limit": {"type": "integer", "default": 50, "maximum": 200}
    }
  }
}
```

Returns: array of pipeline metadata objects. Datasource references are per-node and are read from the body via `pipelines_get` — the listing does not aggregate them.

**`version` is the working version, and `status` names it** (D55/D56, 099): the draft's number when the pipeline has a draft, else the latest released one, with `status` = `DRAFT` or `RELEASED`. Since creation lands a DRAFT, a listing that reported the released pointer alone would show nothing at all for every freshly authored pipeline, and `version: 1` on its own could not tell a reviewed release from a draft nobody has looked at. Both fields are also `null` in the one case where a pipeline has no version at all — its sole draft was purged, which deletes the entity with it ([versioning §3.2](/docs/versioning.md#32-entity-status-is-derived-names-are-unique-forever)).

**Two presentations, chosen by `prefix`** (067). Pipeline names are folder paths ([Pipeline Contract §3.2](/docs/pipeline-contract.md#32-field-reference), [Template Hierarchy §14](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md)), so an agent needs to BROWSE as well as search:

- **`prefix` absent** — the flat listing above, under `owner`/`datasource`/`q`. Unchanged.
- **`prefix` present** (`""` is the ROOT) — **one level** of the tree, and the return shape is an object rather than an array:

  ```jsonc
  {
    "prefix": "nyc",
    "folders": [{"path": "nyc/mobility", "segment": "mobility", "pipeline_count": 6}],
    "pipelines": [ /* the level's own leaves, same metadata objects */ ],
    "total": 0,
    "has_more": false
  }
  ```

  `folders` are the prefix's DIRECT sub-folders with their whole-subtree counts; `pipelines` are its direct children only. Never a subtree, never the whole list. `owner`, `datasource` and `q` are ignored while `prefix` is present — browse and search are different presentations. A prefix is a FOLDER PATH — 1 to 9 segments, not the 2-to-10 a NAME takes since 077 — so `nyc` browses; one that is not a legal folder path answers an empty level, not an error.

**When to use which:** `prefix` to discover structure ("what roots exist? what is under `finance`?"), `q` to find something by name across full paths. Start a naming decision with `prefix: ""`.

**Permission:** `pipeline.read`.

#### 6.2.2 `pipelines_get`

Fetch a full pipeline definition.

```json
{
  "name": "pipelines_get",
  "description": "Get the full definition of a pipeline (the working version by default — the draft when unreleased edits exist, else the latest released version — or a specific version). Use this to read the pipeline body before executing or modifying it. The result carries the version, its status and body_hash — echo body_hash back as expected_hash on pipelines_update; a draft pointer is present when unreleased edits exist. When a node pins a template version that a newer released version outdates, an upgrade_available array names the node, the template and both versions — an offer to re-pin via pipelines_update, never an automatic change. A promoter's key sees only RELEASED pipelines newer than the promotion target's (the promoter lens); every other pipeline is absent for it and resolves as not-found by id.",
  "inputSchema": {
    "type": "object",
    "required": ["id"],
    "properties": {
      "id": {"type": "string", "format": "uuid", "description": "Pipeline ID."},
      "version": {"type": "integer", "description": "Specific version. Defaults to the working version: the draft when one exists, else the latest released."}
    }
  }
}
```

Returns: full pipeline JSON body (per [Pipeline Contract §3](/docs/pipeline-contract.md#3-top-level-pipeline-schema)) merged with the fields the version-lifecycle protocol needs (versioning §4.2, since 035): the version's `body_hash` and `status` — echo `body_hash` back as `expected_hash` on `pipelines_update` — plus `current_version` (the latest RELEASED version, what execute-default runs) and a `draft` pointer when unreleased edits exist. Since 039 the DEFAULT body is the **working version** (versioning §7): the DRAFT when one exists, else the latest released — an agent that read released while a draft was open would rebase on stale content and quietly discard the draft with its next write. The response always states which `version` and `status` it returned; an explicit `version` argument still wins.

Since 078, the body's `parameters` also lists the pipeline's **derived execute inputs**: one entry per key a CALCULATOR node writes (121: every mapped `context_keys` value of a multi-output kind too) — `{"type": <the key's output wire type, or "ANY">, "required": false, "derived": true}` — because a calculator key is an implicit optional input of `pipelines_execute` (pipeline-contract §4.10: supply it and the node is skipped). Declared parameters carry no `derived` flag; absence is the false. Derived on read, never stored — the entries must not be echoed back on `pipelines_update`.

Since 040, the response also carries `upgrade_available` **whenever a node's pinned template has a newer RELEASED version** (040 D5): one `{node, template_id, pinned, latest_released}` row per outdating pin, computed from the very body being returned. Absent when no pin is outdated (omit-when-empty, the envelope convention). Surfaced, never applied — moving a pin is a pipeline edit (`pipelines_update`) and stays the caller's decision; a pin of a template DRAFT version is not an upgrade (the author is ahead of release, which is information, not a prompt). See [Templates §5.4](/docs/templates.md#54-used-by-the-reverse-arrow-v19-040).

**Permission:** `pipeline.read`.

#### 6.2.3 `pipelines_execute`

Execute a pipeline.

```json
{
  "name": "pipelines_execute",
  "description": "Execute a pipeline with the given input parameters. Returns execution events (node start/complete/fail) and the final result data. The result's schema describes column types; BIGINTEGER and BIGDECIMAL columns serialize as JSON strings — preserve them as strings when displaying or persisting to avoid precision loss. When the execution FAILS, the error result carries the full failure record (node context, rendered SQL, exception chain with the root cause last in caused_by) — the same object executions_get returns; quote its correlation_id when escalating. The server checks the loop: a DRAFT whose pinned draft template was updated after this key's last templates_render of it is refused pipeline.execution.template_unrendered — render, then run.",
  "inputSchema": {
    "type": "object",
    "required": ["id", "parameters"],
    "properties": {
      "id": {"type": "string", "format": "uuid"},
      "version": {"type": "integer", "description": "Specific version to run. Defaults to the WORKING version: the draft when one exists, else the latest released. Never clamped — an unknown version is refused, not rounded to the latest."},
      "parameters": {
        "type": "object",
        "description": "Object whose keys match the pipeline's declared parameters. Values must match the declared types (BIGINTEGER and BIGDECIMAL as strings, others as JSON native types).",
        "additionalProperties": true
      }
    }
  }
}
```

Returns: an **execution result object** containing:
- Execution metadata (`execution_id`, `pipeline_id`, `status`, `duration_ms`, `node_stats`).
- Schema (array of column descriptors per [Type System §7](/docs/type-system.md#7-schema-envelope-structure)).
- The **first page of rows** inline (up to `datapipelines.result.page-size-rows`), plus `total_rows`, `has_more`, `result_url`, and `expires_at`.
- Warnings array (if any).

The result shape mirrors the [REST `data_ready` event](/docs/rest-api.md#647-data_ready) exactly — same fields (`schema`, the inline `rows` first page, `row_count`, `total_rows`, `has_more`, `result_url`, `expires_at`, and **`ttl_seconds`** so the agent knows its paging window without diffing timestamps), same uniform delivery model. There is no inline-vs-claim-check split: every caller result is materialized in Redis before the tool returns ([REST API §7.1](/docs/rest-api.md#71-model)). For a result that fits in one page, the inline rows ARE the whole result and no follow-up call is needed; when `has_more` is `true`, page the remainder with `executions_get_result` (§6.2.15) within the TTL.

A pipeline with **no caller node** ([Pipeline Contract §9](/docs/pipeline-contract.md#9-the-caller-node-result-node)) is legal — a pure write-back/ETL pipeline. Such an execution returns metadata, `node_stats`, and no `schema`/`rows`; this is success, not an error.

**Long-running executions.** The tool call is a **single blocking request**: it returns when the execution reaches a terminal state (`SUCCESS`, `FAILED`, `ABORTED`) or when `datapipelines.executor.execution-timeout-seconds` (default 600) elapses and the execution is aborted. For a 3-minute pipeline, the agent experiences one tool call that takes ~3 minutes; the HTTP response for that call stays open for the duration and the server writes nothing to it until the result is ready. (The REST SSE heartbeat, [REST API §6.6](/docs/rest-api.md#66-heartbeat-keepalive), is an SSE-stream concept and does not apply here — an MCP tool call is not an event stream. Operators must therefore ensure proxy/load-balancer idle timeouts on `/mcp` exceed `execution-timeout-seconds`; see [Deployment](/docs/deployment.md).)

MCP **progress notifications** for in-flight nodes are deliberately not implemented in v1 — the tool returns progress only as the final `node_stats`. Streaming execution events through the MCP transport is a v2 item ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)). The v1 stateless transport delivers **no** server-to-client notifications of any kind (§5.1), so `node_stats` in the tool's final result is the authoritative and only per-node record.

**If the agent abandons the call** (aborts the HTTP request, client crash): a blocking `POST /mcp` gives the servlet no disconnect callback, so the `datapipelines.sse.disconnect-grace-seconds` cancellation that a dropped **REST SSE** stream gets ([REST API §6.8](/docs/rest-api.md#68-client-disconnect)) does **not** apply to an abandoned tool call in v1 — the execution runs until it finishes or hits `datapipelines.executor.execution-timeout-seconds`. To stop an in-flight execution deterministically, cancel it: over MCP with `executions_cancel` (§6.2.35) when this key's own MCP calls started it, or out-of-band via `DELETE /api/v1/executions/{id}` ([REST API §10.4](/docs/rest-api.md#104-cancel-execution)) from any instance — in-flight statements are interrupted and the abandoned tool call returns an `ABORTED` result. There is no resumption path (a reconnecting agent must re-execute).

**With no `version`, this runs the WORKING version** (D56, 099): the draft when one exists, else the latest release — "always run the LAST version". On a development server that may well be a draft (drafts have been executable since 039); on a hardened server `authoring-enabled=false` refuses every draft-creating write, so the working version is a RELEASED version by construction ([versioning §7.2](/docs/versioning.md#72-execute-with-no-version-runs-the-working-version-d56-099)). The execution record pins the version that actually ran and the executions screen marks a draft run, so a result is never ambiguous about what produced it. An explicit `version` is exact and never clamped.

**Render before you run (139).** Executing a DRAFT version whose pinned template version is itself a DRAFT written after this key's last successful `templates_render` of it is refused — `pipeline.execution.template_unrendered`, with `details.templates` carrying each `{id, version, updated_at, last_render}`. RELEASED pins are exempt (they rendered before release and cannot change), and so is any execute of a RELEASED version: the check reads the caller's own audit rows, so it is an AGENT-surface rule — REST, the UI and a human's key keep their freedom. A `templates_update` therefore makes the next execute refuse again until a fresh render: the render-then-run loop the SKILL's steps 3 and 5 describe, enforced at the entry point instead of asked of the model a third time. `pipelines_execute_node` (§6.2.20) runs the same check on the one node it resolves.

**Permission:** `pipeline.execute`.

#### 6.2.4 `pipelines_create`

Create a new pipeline.

```json
{
  "name": "pipelines_create",
  "description": "Create a new pipeline. The body must satisfy the Pipeline Contract: nodes must form a DAG; at most one DQL node may resolve to output.target='caller' (a node that omits its output block resolves to 'caller' by default); zero caller nodes is legal for pure write-back pipelines; all datasource references must exist in this environment; all template references must exist and dry-render against the declared parameters. A node may also be type='CALCULATOR': it evaluates one catalog function and writes a typed value into the execution Context under context_key, which downstream nodes bind as :context_key — call calculators_list first for the kinds and their input names, and remember that a node referencing another node's context_key must depend_on it. A NEW top-level folder is refused until you confirm it: reuse an existing root, or ask the person first and then pass confirm_new_root: true. The server checks what you learned: a body whose template names a table this key never datasources_get_columns'd is refused pipeline.validation.table_not_learned with the clearing calls listed, and a raw-date door (two DATE parameters, no period parameter, no window calculator) is refused pipeline.validation.door_unacknowledged until you pass door_acknowledged: true. Returns the created pipeline with server-assigned id and version 1, which lands as a DRAFT: run it straight away, then STOP — a human releases it from the UI, and no tool releases anything.",
  "inputSchema": {
    "type": "object",
    "required": ["name", "display_name", "nodes"],
    "properties": {
      "name": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_.-]{0,63}(/[a-z0-9][a-z0-9_.-]{0,63}){1,9}$", "description": "Machine name, and a FOLDER PATH: 2-10 lower-case '/'-separated segments (finance/payments/daily_settlement). A FOLDER IS REQUIRED — a bare 'daily_settlement' is refused with pipeline.validation.name_invalid and details.reason='folder_required'; put experiments under test/. The root segment says who owns it — list the existing roots with pipelines_list {prefix: ''} and reuse one; ASK before minting a new root. Keep a pipeline under the same prefix as the templates it uses. There is no rename: the name is the pipeline's identity, so choose the folder now."},
      "display_name": {"type": "string"},
      "description": {"type": "string"},
      "parameters": {"type": "object", "description": "Declared pipeline parameters (name -> {type, required, default, description}). This is the ONLY parameter declaration point: the full parameter map, defaults applied, is the render context for every template the pipeline references."},
      "settings": {"type": "object", "description": "Pipeline-level execution settings (e.g., tempdb engine)."},
      "nodes": {
        "type": "array",
        "description": "Pipeline nodes. Each node has type (DQL/DML/DDL/PIPELINE), source, template ref, depends_on array, and — for DQL only — an optional output block. Omitting output on a DQL node means output.target='caller'; at most one node per pipeline may resolve to 'caller'. A node whose data downstream nodes query must declare output.target='tempdb' with a table name explicitly. A PIPELINE node instead carries a pipeline ref {name, version} pinning an existing pipeline version to execute as a child execution, an optional parameters map (typed literals, or '${parent_param}' to pass a parent parameter through), and an optional output block allowed only when the pinned child has a caller node; it declares neither source nor template."
      },
      "checks": {
        "type": "array",
        "description": "Release checks (pipeline-contract §3.3): at most 20 objects, each {id, name, datasource, sql, expected} — id [a-z0-9_]{1,63} unique in the body, name 1-200 chars, sql ONE read-only statement. expected.kind is value (single numeric cell compared with absolute tolerance, default 0), range (the cell within min..max inclusive), or rows (the statement's row count equals rows). Every :name bind must name a DECLARED pipeline parameter (the calculator context is not available to a check); ${} interpolation is refused (a check has no rendering); tempdb is not a check datasource. You supply the query and the expectation, never an observed value — run them with pipelines_run_checks, and only the server's run produces observed."
      },
      "confirm_new_root": {"type": "boolean", "description": "Set true ONLY after a person has agreed to a new top-level folder. A name whose root segment has no pipelines or templates under it yet is refused with details.existing_roots listing the roots that do exist — reuse one of those, or ask the person first and then pass this. 'test/' never needs it."},
      "door_acknowledged": {"type": "boolean", "description": "Set true ONLY when the question truly fixes two dates. A pipeline whose parameters are two raw DATE inputs with no period parameter (year, quarter, month, *_year) and no window CALCULATOR node is refused pipeline.validation.door_unacknowledged — the door is a decision: prefer the period vocabulary of rule 13, and never pass this to silence the refusal."}
    },
    "additionalProperties": false
  }
}
```

Returns: created pipeline — `id`, `version: 1`, **`status: "DRAFT"`**, `body_hash` (carry it into your next `pipelines_update`), `current_version: null` (nothing released yet) and the `draft` pointer.

**Creation lands a DRAFT (D55, 099).** `POST /pipelines` used to land version 1 RELEASED so that an MCP-authored pipeline was immediately executable; drafts have been executable since 039, so that justification bought nothing and cost a review — an agent following the old rule produced a released pipeline no human had looked at. The golden path for an agent is therefore: create → execute (no `version`, which runs your draft) → read the result → **stop and tell the person it is ready to review**. Releasing is a human action in the UI; there is no release tool and there will not be one (versioning D4).

**A new ROOT folder needs the person's say-so (094).** The root segment says who owns a thing and there is no rename, so this tool REFUSES a name whose first segment has nothing under it yet — `pipeline.validation.new_root_requires_confirmation`, with `details.root` and `details.existing_roots` (the same one-level query `pipelines_list {"prefix": ""}` serves). Reuse one of those roots, or ask the person and retry with `confirm_new_root: true`. `test/` is always allowed. This is an AGENT-surface rule only: REST, the UI and `pipelines_update` are unaffected — a person choosing a folder in a form has already decided, and an update cannot change a name. It replaces an INSTRUCTION with a GUARANTEE: the schema and the SKILL already told an agent to list the roots and ask, and a model that did not, did not.

**Learn before you write — the server now checks it (139).** A body whose template names a table THIS API key never read the columns of is refused — `pipeline.validation.table_not_learned`, with `details.tables` listing each `{datasource, table}` and the one `datasources_get_columns` call that clears it. The check reads the caller's own `mcp.tool.called` audit rows (any time the audit log still holds the row — a key learns a table once per [`datapipelines.audit.retention-days`](/docs/configuration.md#312-audit), default a year, since the rows it reads age out with the log ([Auth §10.3](/docs/auth.md#103-retention)); rows written before 139 carry no `table` and simply do not count), tokenises each pinned template's body the way the semantics fact check does, and compares against the source datasource's catalog listing. `tempdb` sources, `${…}` dynamic names and names the catalog does not list are exempt; `_get_table_stats` is NOT required — columns are correctness, stats are performance. Run it at `pipelines_create` and `pipelines_update`, because only there is the node's datasource known; the template tools themselves are unchecked (a template has a dialect, never a datasource). REST and the UI are unaffected.

**The door is a decision (139).** A body whose parameters contain two DATE parameters with no INTEGER period parameter (`year`, `quarter`, `month`, `*_year`) and no CALCULATOR node whose kind writes a DATE — a raw-date door — is refused `pipeline.validation.door_unacknowledged`, naming the two parameters and rule 13's alternatives (a period parameter, or an anchor date with a `period_bounds` / `trailing_periods` node), unless the call carries `door_acknowledged: true`. The flag is the `confirm_new_root` shape: it forces the decision instead of a copy, and passing it to silence the refusal is precisely the miss it exists to catch. REST and the UI are unaffected.

The whole pipeline is validated before it is stored — no invalid pipeline ever reaches the database ([Pipeline Contract §2](/docs/pipeline-contract.md#2-design-principles)). Validation failures come back as a tool result with `isError: true` carrying the pipeline validation code (§9.2); the agent should fix and retry rather than assume partial creation.

**Permission:** `pipeline.create`.

#### 6.2.5 `pipelines_update`

Update an existing pipeline by writing its DRAFT (versioning §3.2/§7, since 035).

The tool's shipped description (the one block of §6.2 with no `inputSchema` — the input is the prose below; `McpToolSurfaceSpecDriftTest` pins the string, #235):

```json
{
  "name": "pipelines_update",
  "description": "Update an existing pipeline by writing its DRAFT — the first update after a release creates the draft (copy-on-write); later updates overwrite that same draft in place. Requires expected_hash: the body_hash you read (pipelines_get, or a previous update's result) for the version you based your edit on. The result carries status='DRAFT' — your work is NOT released; a human releases it from the UI. On pipeline.version.conflict someone modified it after you loaded it: re-read, rebase, retry; never retry blindly. The body takes the same node types as pipelines_create, CALCULATOR included (calculators_list has the kinds); no extra arguments are needed for one. The same entry-point checks apply: a body naming a table this key never datasources_get_columns'd is refused pipeline.validation.table_not_learned, and a raw-date door is refused pipeline.validation.door_unacknowledged until door_acknowledged: true."
}
```

Same input as `pipelines_create` plus required `id` and required `expected_hash` — the `body_hash` of the version this edit is based on (`pipelines_get` or the previous update's result). The first update after a release creates the draft (copy-on-write); later updates overwrite that same draft in place. Same save-time validation applies — **and the same 139 entry-point checks**: a body naming a table this key never read the columns of is refused `pipeline.validation.table_not_learned`, and a raw-date door is refused `pipeline.validation.door_unacknowledged` until `door_acknowledged: true` (an update can change a body's parameters, unlike its name, so the door check belongs here too). That input includes the optional `checks[]` (§6.2.4's `checks` property, pipeline-contract §3.3) — the release checks the SERVER runs; run them with `pipelines_run_checks` (§6.2.42), and only the server's own run produces `observed`.

Returns: the draft version — `version`, `status: "DRAFT"`, `body_hash` (carry this into the next write), `current_version` (the unmoved released pointer), and the `draft` pointer. **The update does NOT release**: an agent leaves the draft for a human to review and release from the UI (versioning D4). On `pipeline.version.conflict` someone else modified it after you loaded it — re-read, rebase, retry; never retry blindly.

**Permission:** `pipeline.update`.

#### 6.2.6 `templates_list`

List templates.

```json
{
  "name": "templates_list",
  "description": "List the templates of the key's pinned workspace. Templates are reusable generators referenced by id+version — sql/html bodies are authored in Freemarker, script bodies are script expressions; each has a fixed type — 'sql' renders SQL for pipeline nodes (and carries a dialect), 'html' renders escaped output and declares none, and the script types ('jsonata', 'javascript') evaluate the body as a pure function of its input and declare neither. Template ids are unique per workspace — another workspace's template resolves as not-found. A script row carries implements (the learned facts it cites) and needs_review (true when a cited fact was retired); implements=<fact id> lists the scripts that implement that fact — reuse before writing. A promoter's key sees only RELEASED templates newer than the promotion target's (the promoter lens); every other template resolves as not-found.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "dialect": {"type": "string", "enum": ["POSTGRES", "ORACLE", "MSSQL", "MYSQL", "H2", "DUCKDB", "SQLITE", "LAKE"]},
      "type": {"type": "string", "enum": ["sql", "html", "jsonata", "javascript"], "description": "Filter by template kind: 'sql' (pipeline-referenced SQL), 'html' (rendered output), or a script type ('jsonata', 'javascript' — a pure function over its input, never rendered)."},
      "q": {"type": "string", "description": "Case-insensitive substring search over name and display name (the whole path counts as the name); ignored when prefix is present — pass prefix to browse, q to search. At most 200 characters after trimming; a longer q is refused as invalid params."},
      "prefix": {"type": "string", "description": "Browse ONE level of the folder tree instead of listing flat: returns that prefix's direct sub-folders (with counts) and its direct children. An empty string is the root. Use this to discover which roots and folders exist; use q to search across full paths."},
      "is_library": {"type": "boolean", "description": "Filter to library templates (macro collections) or executable templates."},
      "implements": {"type": "string", "format": "uuid", "description": "A learned fact id: only templates whose listed version cites it (implements) — the scripts that implement a workspace definition, exclusion or preference. A fact this workspace cannot see matches nothing. Ignored while prefix is present, like q."},
      "limit": {"type": "integer", "default": 50, "maximum": 200}
    }
  }
}
```

Returns: array of template metadata (`id`, `version`, `type`, `dialect`, `display_name`, `description`, `is_library`, `implements`, `needs_review`; `dialect` is null for `html` templates, since 046; `implements` is null on `sql`/`html` and the version's cited learned-fact ids on a script, since 7e). A template's `description` is the only place it can hint at the parameters it expects — templates declare none ([Templates §3.2](/docs/templates.md#32-field-reference)).

**`implements=<fact id>` — discovery (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)).** Keeps the templates whose LISTED version (the same row the flat listing returns — the current release, or the draft of a never-released template) cites that learned fact: the scripts that implement a workspace `definition`, `exclusion` or `preference`. Reuse one before writing your own. The filter is a parameterised join through the template's own workspace, so an id another workspace owns — or no fact at all — matches nothing (the read floor's not-found semantics); a malformed id is `-32602`. Like `q`, it is ignored while `prefix` is present. The reverse question — which versions implement THIS fact — is `implemented_by` on [`semantics_list`](#6238-semantics_list) and on the `definitions` of `datasources_list`/`datasources_get`. `needs_review` is `true` when a cited fact has been retired (computed on read, never stored — §6.2.7).

**Two presentations, chosen by `prefix`** (067). Template ids have been paths since 043 and the templates browser has rendered them as a tree since 047, but this tool had no way to browse a folder at all until now:

- **`prefix` absent** — the flat listing above. Unchanged.
- **`prefix` present** (`""` is the ROOT) — **one level**, the same shape `pipelines_list` returns with `prefix`, keyed `templates` instead of `pipelines` and `template_count` instead of `pipeline_count`:

  ```jsonc
  {
    "prefix": "nyc",
    "folders": [{"path": "nyc/mobility", "segment": "mobility", "template_count": 7}],
    "templates": [ /* the level's own leaves, same metadata objects */ ],
    "total": 0,
    "has_more": false
  }
  ```

  `dialect` and `type` still narrow both halves, so a folder whose whole subtree is filtered out is absent rather than empty. `is_library` narrows the level's leaves only — a folder count is over the whole subtree, and quietly subtracting library templates from it would make the tree disagree with what expanding the folder shows. `q` is ignored while `prefix` is present.

**When to use which:** `prefix` to browse, `q` to search. A pipeline and the templates it uses should share a prefix — see [Template Hierarchy §15](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md).

**Permission:** `template.read`.

#### 6.2.7 `templates_get`

Fetch a template body.

```json
{
  "name": "templates_get",
  "description": "Get the body and metadata of a template version, including its imports array (the library macros it can call). Defaults to the working version — the draft when unreleased edits exist, else the latest released. A script version carries implements (the learned facts it cites) and needs_review: true when a cited fact was retired — retired_facts names each one and its superseded_by; re-verify, then cite the successor with templates_update. A promoter's key sees only RELEASED templates newer than the promotion target's (the promoter lens); every other template resolves as not-found.",
  "inputSchema": {
    "type": "object",
    "required": ["id"],
    "properties": {
      "id": {"type": "string"},
      "version": {"type": "integer", "description": "Specific version. Defaults to the working version: the draft when one exists, else the latest released."}
    }
  }
}
```

**Permission:** `template.read`. The returned projection states its `version` and `status` — since 039 the default is the **working version** (versioning §7: the DRAFT when one exists, else the latest released), the template mirror of `pipelines_get`.

**The semantic link on read (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)).** A script version carries `implements` — the learned-fact ids it cites, sorted (`[]` when none; null on `sql`/`html`) — and every version carries `needs_review`: `true` when any cited fact has been RETIRED (a superseded fact is a retired one), computed on read by the version's own query and never stored. When it is true, `retired_facts` names each retired citation as `{fact_id, retired_reason, superseded_by}` — `superseded_by` is the fact that replaced it (null for a plain retirement); `retired_facts` is omitted when empty. The version is served marked, never edited and never re-mapped: re-verify, then clear it deliberately with `templates_update` citing the successor (or dropping the citation) — on a released version that write opens no draft (§6.2.36). A pipeline release pinning a `needs_review` version is not refused; its response carries a `pipeline.release.template_needs_review` warning ([Pipeline Contract §13.13](/docs/pipeline-contract.md#1313-versioning--draft-release-lifecycle--promotion)).

#### 6.2.8 `templates_create`

Create a new template.

```json
{
  "name": "templates_create",
  "description": "Create a new template. Templates use Freemarker syntax. A template declares NO parameters of its own: the variables its body may reference are exactly the parameters declared by the pipeline that calls it, with defaults applied. Describe the variables you expect in 'description' — that free text is how humans and agents discover them. Macros from library templates are made available by listing them in 'imports'; the body must NOT contain import or include directives, they are synthesized from the imports array. The 'type' is chosen here and never changes afterwards: 'sql' (default) requires a dialect and is what pipeline nodes reference; 'html' takes no dialect and renders through an auto-escaping engine. A NEW top-level folder is refused until you confirm it: reuse an existing root, or ask the person first and then pass confirm_new_root: true. Version 1 lands as a DRAFT: a pipeline draft may pin it and render against it while you iterate, and a human releases it from the UI — a RELEASED pipeline may only pin RELEASED template versions, so the template is released first.",
  "inputSchema": {
    "type": "object",
    "required": ["display_name", "description", "body"],
    "properties": {
      "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_.-]{0,63}(/[a-z0-9][a-z0-9_.-]{0,63}){1,9}$", "description": "Template id, and a FOLDER PATH: 2-10 lower-case '/'-separated segments (acme/finance/daily_orders.sql). A FOLDER IS REQUIRED — a bare 'daily_orders.sql' is refused with template.validation.id_invalid and details.reason='folder_required'; put experiments under test/, and shared macros under <owner>/lib/. Keep a template under the same prefix as the pipelines that read it. Optional; auto-generated if omitted. There is no rename, so choose the folder now."},
      "engine": {"type": "string", "enum": ["freemarker", "none"], "default": "freemarker", "description": "Template engine, matched to the type: 'freemarker' for sql/html, 'none' for the script types ('jsonata'/'javascript' — the body is evaluated, never rendered). Any other pairing is refused with template.validation.engine_unsupported."},
      "type": {"type": "string", "enum": ["sql", "html", "jsonata", "javascript"], "default": "sql", "description": "Template kind, fixed at creation and identical on every version: 'sql' renders SQL for pipeline nodes (requires 'dialect'); 'html' renders HTML through an auto-escaping engine (must have NO 'dialect'); 'jsonata' and 'javascript' are script types — a JSONata expression or the statements of main(input), evaluated as a pure function of its input, engine is 'none', dialect/imports/is_library are refused, and contract/invariants/tests blocks are required. JavaScript requires the installed isolate; otherwise save refuses with script.js.unavailable."},
      "dialect": {"type": "string", "enum": ["POSTGRES", "ORACLE", "MSSQL", "MYSQL", "H2", "DUCKDB", "SQLITE", "LAKE"], "description": "SQL execution target. Required when type is 'sql' (the default); forbidden otherwise — html and the script types declare no dialect."},
      "display_name": {"type": "string"},
      "description": {"type": "string", "description": "Free text. State the variables the body expects and their types — the template declares none."},
      "imports": {
        "type": "array",
        "description": "Library templates whose macros this body calls. Aliases must be unique within the template; each referenced template must exist at that exact version and be is_library=true.",
        "items": {
          "type": "object",
          "required": ["id", "version", "alias"],
          "properties": {
            "id": {"type": "string"},
            "version": {"type": "integer"},
            "alias": {"type": "string", "description": "Namespace the macros are bound to, e.g. 'dates' → <@dates.date_range .../>."}
          },
          "additionalProperties": false
        }
      },
      "is_library": {"type": "boolean", "default": false, "description": "true if this template exists to be imported by others. A library body contains only <#macro>/<#function> definitions — no output outside macro definitions. body is still required."},
      "body": {"type": "string", "description": "Template source. Must not contain <#import> or <#include>."},
      "contract": {"type": "object", "description": "Script contract (script types only — refused on sql/html with template.blocks_not_allowed): { mode: 'row'|'table'|'value', inputs: { name: { kind: 'table', columns: [{name, type, precision?, scale?, nullable?}] } or { kind: 'value', type, precision?, scale? } }, output: { kind: 'table'|'value'|'object', ... }, rejects?: boolean }. Types are LogicalType wire names; a row-mode contract requires exactly one table input."},
      "invariants": {"type": "array", "description": "Script invariants: [{ name, expr, message }] — JSONata over { rows, rejects, inputs }, must be true on every test case and every real execution. May be empty but is required on a script type."},
      "tests": {"type": "array", "description": "Script test cases: [{ name, input: { rows?, inputs?, meta?, now? }, expect: { output } or { refusal } }] — non-empty, at least one case whose every table input and rows are empty, expect is exactly one of output/refusal. Save runs the suite; release re-runs it."},
      "implements": {"type": "array", "items": {"type": "string", "format": "uuid"}, "description": "Script types only (refused on sql/html with template.blocks_not_allowed): the learned facts this script implements — ids of WORKSPACE facts (definition, exclusion, preference) recorded in this workspace; semantics_list and the definitions on datasources_list show them. A citation is not content: it is outside body_hash. An id this workspace cannot cite is template.implements_unresolved. At most 50."},
      "confirm_new_root": {"type": "boolean", "description": "Set true ONLY after a person has agreed to a new top-level folder. A name whose root segment has no pipelines or templates under it yet is refused with details.existing_roots listing the roots that do exist — reuse one of those, or ask the person first and then pass this. 'test/' never needs it."}
    },
    "additionalProperties": false
  }
}
```

**A new ROOT folder needs the person's say-so (094).** Same rule as [§6.2.4](#624-pipelines_create), same `confirm_new_root` argument, same `details.root` / `details.existing_roots` shape — the code is `template.validation.new_root_requires_confirmation` and the roots come from `templates_list {"prefix": ""}`. An OMITTED `id` is generated under `test/` and needs no confirmation.

Save-time validation is **parse-only** — syntax, forbidden constructs, import resolution, and the type/dialect consistency rules (`sql` requires `dialect`, `html` forbids it; a payload trying to change an existing template's `type` is refused — [Templates §7.1](/docs/templates.md#71-save-time-validation-is-parse-only)). A template is never rendered against a sample context at save time, because it does not know its callers' parameters; the dry-render check happens when a *pipeline* referencing it is saved ([Templates §7.2](/docs/templates.md#72-the-dry-render-rule-owned-by-pipeline-validation)). An agent authoring a template should therefore call `templates_render` (§6.2.9) with a representative context to confirm the output it produces.

**`implements` (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)).** A script may cite the learned facts it implements — ids of WORKSPACE facts (`definition`, `exclusion`, `preference`) recorded in this workspace, at most 50. Each id must resolve to such a fact visible from the workspace, else `template.implements_unresolved` (400, `details.fact_id` + `details.reason` = `unknown` | `malformed` | `too_many` — one `unknown` answer for "no such fact", "another workspace's" and "a DATASOURCE fact", never "it exists but you cannot see it"); on `sql`/`html` the field is refused with `template.blocks_not_allowed`. A citation is not content: it is outside `body_hash`. The result is the stored projection, `implements` included.

**Permission:** `template.create`.

#### 6.2.9 `templates_render`

Render a template against a supplied context (preview SQL).

```json
{
  "name": "templates_render",
  "description": "Render a template against the provided context values and return the SQL it produces. Use this to preview generated SQL before creating a pipeline that references the template. The context is a free-form map: supply the same keys the calling pipeline would declare as parameters. Referencing a key absent from the context fails the render — that is the same failure a pipeline save would report.",
  "inputSchema": {
    "type": "object",
    "required": ["id", "context"],
    "properties": {
      "id": {"type": "string"},
      "version": {"type": "integer", "description": "Defaults to latest."},
      "context": {
        "type": "object",
        "description": "Render context: the parameter map a calling pipeline would provide, defaults already applied. Values follow the wire conventions of the Type System (BIGINTEGER/BIGDECIMAL as strings, TIMESTAMP with Z or offset).",
        "additionalProperties": true
      }
    },
    "additionalProperties": false
  }
}
```

Returns: rendered SQL string. This is a preview only — nothing is executed and nothing is stored.

**Permission:** `template.render` (it is the authoring loop's preview step; see the Auth §7.6 matrix).

#### 6.2.10 `datasources_list`

List registered datasources (without credentials).

```json
{
  "name": "datasources_list",
  "description": "List the datasources GRANTED to the key's pinned workspace. Visibility is the grant: a datasource registered elsewhere and not granted to this workspace is ABSENT, not hidden, and there is no such thing as a global datasource. This is the learn-first read: every entry carries name, dialect, description, the readonly flag, the workspace that REGISTERED it (omitted for an instance-level one), granted:true, connection metadata — never passwords — AND `facts`, the datasource-wide learned facts earlier sessions recorded (its time window, whether it is a sample), each with `trust` and evidence — read them before you assume coverage — AND `definitions`, this workspace's rules (definition, exclusion, preference) earlier pipelines chose on this datasource: a definition is a rule an earlier pipeline chose; read it before you choose yours, and reuse or supersede it, never re-choose. `datasources_get` is the same payload for ONE datasource — the refresh to call after recording facts with semantics_record.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "dialect": {"type": "string"}
    }
  }
}
```

**Permission:** `datasource.read`. (Registering, editing and deleting a datasource are UI/REST-only — [§6.2.22](#6222-removed--no-datasource-writes-on-this-surface): no credential travels through an agent.) The listing is scoped to the key's pinned workspace exactly like REST §9.2.

**Returns:** one entry per granted datasource in the §6.2.11 per-datasource shape, `facts` **included** (126): the datasource-wide LEARNED FACTS (kinds `window` and `sampling`, the [learned-semantic-layer design](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md) §7.2) in the §7A.5 fact shape — `[]` when none are recorded, an empty array never an absent key, so "nothing recorded" reads differently from "not served". Served as stored: the listing opens no connection, so there is nothing to recompute drift against (the 118 rule), and one enrichment read runs per listed datasource. **`definitions` included** (136 §A / T287): every WORKSPACE-scope fact (`definition`, `exclusion`, `preference`) visible to the reader that names this datasource — with refs on its tables or columns, or with none (§6.2.37, a rule that spans datasources) — in the same §7A.5 fact shape, newest last, `[]` when none; **a definition is a rule an earlier pipeline chose; read it before you choose yours**. `facts` keeps its datasource-wide meaning (`window`, `sampling`) — a rule is never in both. Both blocks come from the one enrichment read. **Every rule under `definitions` carries `implemented_by`** (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)): `[{template_id, version}]`, the template versions that cite it as the script implementing it — `[]` when none — filtered by the key's template lens (a promoter's key lists RELEASED versions of the templates it may read). The `dialect` filter behaves as before.

#### 6.2.11 `datasources_get`

Fetch a single datasource (without password).

```json
{
  "name": "datasources_get",
  "description": "Get metadata for a single datasource GRANTED to the key's pinned workspace: name, dialect, JDBC URL, the workspace that REGISTERED it (omitted for an instance-level one), granted:true, readonly flag, pool settings, `facts` — the datasource-wide learned facts agents recorded (its time window, whether it is a sample) — read them before you assume coverage — and `definitions`, this workspace's rules (definition, exclusion, preference) earlier pipelines chose on this datasource: a definition is a rule an earlier pipeline chose; read it before you choose yours. Credentials are never returned. A datasource that is not granted to this workspace resolves as not-found — the same answer a name that exists nowhere gets, so nothing about it can be probed.",
  "inputSchema": {
    "type": "object",
    "required": ["name"],
    "properties": {
      "name": {"type": "string"}
    }
  }
}
```

**Permission:** `datasource.read`.

**Returns:** `name`, `display_name`, `description`, `dialect`, `jdbc_url`, `username`, `query_timeout_seconds`, `pool` (the hikari map), `readonly` (boolean — the §5.7 flag, machine-readable so an agent can see BEFORE authoring that DML/DDL/output-datasource uses will be refused), `granted` (always `true` — you are seeing this row because your workspace holds a grant on it, D-R7), `workspace` (the name of the workspace that REGISTERED it, **omitted** when a super admin registered it at the instance level — it is never `null`, because the retired `null = global` reading would be the wrong answer to "who can see this?"; visibility is `granted`) — plus `introspection_include_schemas` ([Datasources §3.3](/docs/datasources.md#33-field-reference)) **when the allowlist is non-empty** (omitted when empty, the same envelope convention as REST §3.2), so an agent debugging why a schema is or isn't visible in the §6.2.16–18 introspection tools can see that an allowlist is active. Credentials are never returned. `datasources_list` (§6.2.10) emits the same per-datasource shape, `facts` included (126 — the listing is the learn-first read, so the facts ride the call every agent already makes): the datasource-wide LEARNED FACTS (kinds `window` and `sampling`, the [learned-semantic-layer design](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md) §7.2), an array in the §7A.5 fact shape below, `[]` when none; served as stored, because this read opens no connection and has nothing to recompute drift against — and `definitions` (136 §A), the same block §6.2.10 describes: this workspace's rules (`definition`, `exclusion`, `preference`) on this datasource, newest last, `[]` when none; a definition is a rule an earlier pipeline chose; read it before you choose yours.

#### 6.2.12 `datasources_test`

Test that a datasource connection can be established.

```json
{
  "name": "datasources_test",
  "description": "Test connectivity to a datasource. Returns success/failure and server version on success. Useful for diagnosing pipeline connection errors.",
  "inputSchema": {
    "type": "object",
    "required": ["name"],
    "properties": {
      "name": {"type": "string"}
    }
  }
}
```

Returns: `{connected: bool, server_version: string?, error: string?}`.

**Permission:** `datasource.test` — testing a connection opens a real pool against a production database; the test follows execute (ratified 2026-09-20), so every role that executes holds it (C1) and the promoter does not.

#### 6.2.13 `executions_list`

List recent executions.

```json
{
  "name": "executions_list",
  "description": "List recent pipeline executions of the key's pinned workspace, optionally filtered by pipeline or status. The key acts as its own role: you see the runs you fired, and every run a schedule fired (a scheduled run is nobody's own — it ran as the system identity), plus every run of the workspace when the key's role holds execution.read_all (a workspace-admin-role key). Other members' own interactive runs are not listed.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "pipeline_id": {"type": "string", "format": "uuid"},
      "status": {"type": "string", "enum": ["RUNNING", "SUCCESS", "FAILED", "ABORTED"]},
      "limit": {"type": "integer", "default": 50, "maximum": 200}
    }
  }
}
```

**Permission:** `execution.read`.

**Scheduled runs (#9 R3, #250):** the list reads the same `findVisible` predicate the REST listing does (rest-api §10.1) — own runs plus every `triggered_via = SCHEDULE` run of the workspace, in SQL, before the page is cut; `executions_get` and `executions_get_result` answer for a scheduled run the same way. The tool's own role decides (keys v2).

#### 6.2.14 `executions_get`

Fetch metadata for a specific execution (no rows).

```json
{
  "name": "executions_get",
  "description": "Get metadata for a specific execution: status, timing, node_stats, parameters used. On a FAILED execution, error carries the full failure record: code, message, correlation_id, node context (datasource, dialect, pinned template), the rendered SQL (:name form, no bound values) and the exception chain with stack frames — read error.code first, then error.exception.caused_by (root cause LAST), then error.sql; quote error.correlation_id when escalating. To get the result rows, use executions_get_result. Visible for YOUR OWN runs (this key's own), every run a schedule fired, or any run of the workspace when the key's role holds execution.read_all; another member's own interactive execution is not found.",
  "inputSchema": {
    "type": "object",
    "required": ["execution_id"],
    "properties": {
      "execution_id": {"type": "string", "format": "uuid"}
    }
  }
}
```

**Permission:** `execution.read`.

**Response (057, on a `FAILED` execution):** `error` is the full failure record — the same object the SSE stream carried and `error_json` stores. (Plain fence: an example, not a tool definition — §6.2's `json` fences are exactly the input schemas `McpToolSurfaceSpecDriftTest` pins.)

```
{
  "status": "FAILED",
  "failed_node_id": "stage_daily_trips",
  "error": {
    "code": "pipeline.node.datasource_connection_failed",
    "message": "Failed to initialize pool",
    "details": {"phase": "connect", "node_id": "stage_daily_trips"},
    "correlation_id": "1b0e6a52-…",
    "node": {"id": "stage_daily_trips", "type": "DQL", "datasource": "sample-trips", "dialect": "POSTGRES",
             "template": "nyc/mobility/sample_trips_daily.sql", "template_version": 1},
    "sql": "SELECT * FROM trips WHERE borough = :borough",
    "exception": {
      "class": "java.lang.RuntimeException", "message": "Failed to initialize pool",
      "frames": ["…capped at 40 per level…"],
      "caused_by": [
        {"class": "org.postgresql.util.PSQLException",
         "message": "FATAL: password authentication failed for user \"dp_demo_ro\"", "frames": ["…"]}
      ]
    }
  }
}
```

Read `error.code` first, then `error.exception.caused_by` (outermost first — the **root cause is the LAST entry**), then `error.sql`. Quote `error.correlation_id` when escalating to a human; it is the field that joins the page to the server log. Under `datapipelines.executions.error-detail=structured` (Configuration §3.11) the `exception` and `sql` keys are absent; everything else stays. `pipelines_execute`'s failure result (§6.3) carries the same record inline, so a failed run's error needs no second call.

#### 6.2.15 `executions_get_result`

Fetch result rows (paginated) for a completed execution.

```json
{
  "name": "executions_get_result",
  "description": "Fetch result rows for a completed execution, paginated via offset+limit. Returns schema + rows + pagination metadata. Works for ANY completed execution that produced a caller result, of any size, until its TTL expires (default 300s, set at execution time). Order is stable across pages. Reading pages does NOT extend the TTL — after expiry the result is gone and the pipeline must be re-run. Readable for YOUR OWN runs (this key's own), every run a schedule fired, or any run of the workspace when the key's role holds execution.read_all; another member's own interactive execution is not found.",
  "inputSchema": {
    "type": "object",
    "required": ["execution_id"],
    "properties": {
      "execution_id": {"type": "string", "format": "uuid"},
      "offset": {"type": "integer", "default": 0, "minimum": 0},
      "limit": {"type": "integer", "default": 1000, "minimum": 1, "maximum": 100000, "description": "Rows per page. Defaults to the server's result page size."},
      "format": {"type": "string", "enum": ["json", "arrow", "csv"], "default": "json"}
    },
    "additionalProperties": false
  }
}
```

This tool is a thin adapter over the REST cursor, [REST API §7](/docs/rest-api.md#7-result-delivery) — **identical semantics, identical guarantees**:

- `offset` / `limit` / `format` map one-to-one onto the cursor's query parameters. `offset` + `limit` paging over a result fully materialized in Redis before the cursor exists, so ordering is stable across pages.
- Availability is uniform: every completed execution with a caller node has its result stored, regardless of size. There is no inline-vs-claim-check distinction to reason about (that split was removed in REST API v1.3).
- TTL is fixed at result-write time (`datapipelines.result.ttl-default-seconds`, clamped between the min/max keys; a client may request one on the *execute* call via `DP-Result-TTL-Seconds`). Page reads never extend it.
- Auth: `execution.result.read` **plus ownership** of the execution — `execution.read_all` (a workspace admin) may read any. Same rule as the REST cursor; the `result_url` is not a capability URL.

**JSON format** returns `{schema, rows, row_count, offset, limit, total_rows, has_more, expires_at}` — same body as [REST §7.3](/docs/rest-api.md#73-response-json-format-default).

**Binary columns and non-JSON formats.** `BINARY` column values in JSON results are base64 per the Type System's egress rules. For `format: "arrow"` or `"csv"`, and for any result whose encoded payload would exceed **1 MB**, the tool does **not** inline the bytes: it returns `{"result_url": "...", "expires_at": "...", "format": "...", "total_rows": N, "reason": "payload_exceeds_inline_cap"}` — the REST cursor URL, which the agent fetches with the same API key. Rationale: MCP tool results are model context; megabytes of base64 in a tool result poison an agent's window for no benefit. Payloads at or under the cap are inlined as base64 with their content type named.

**Errors** mirror [REST §7.6](/docs/rest-api.md#76-endpoint-errors) exactly, returned as tool results with `isError: true` (§9.2) — registry of record [Pipeline Contract §13.10](/docs/pipeline-contract.md#1310-result-retrieval):

| Code | Meaning for the agent |
|---|---|
| `result.execution_not_found` | Unknown execution id — check `executions_list`. |
| `result.execution_incomplete` | Still running; wait or re-check with `executions_get`. |
| `result.execution_failed` | The execution failed; there is no result. Use `executions_get` for the failure. |
| `result.expired` | TTL elapsed. Re-run the pipeline — the result is unrecoverable. |
| `result.format_unsupported` | Unknown `format` value. |

**Permission:** `execution.result.read` (+ ownership).

#### 6.2.16 `datasources_get_schemas`

List a datasource's schemas — the entry point of the introspection flow.

```json
{
  "name": "datasources_get_schemas",
  "description": "List the namespaces of a registered datasource by reading its live JDBC metadata, excluding the engine's own system schemas. The entry point of schema discovery: call this first, then get_tables(namespace), then get_columns for only the tables the SQL needs. Each entry carries an ordered `namespace` path and a `label`; on a two-level engine (catalog.schema, project.dataset) two entries can share a label and differ only by their outer segment, so pass the whole `namespace` back rather than the label. `schemas` repeats the labels for older clients. An empty list on a datasource with no namespaces is a valid answer. Read-only, for pipeline authoring.",
  "inputSchema": {
    "type": "object",
    "required": ["name"],
    "properties": {
      "name": {"type": "string", "description": "Datasource name."}
    }
  }
}
```

Returns: `{"schemas": ["label", ...], "entries": [{"namespace": [...], "label": "..."}], "truncated": bool}` — the namespaces exactly as the driver reported them, as a page. **Read `entries`**: `namespace` is the ordered path (outermost first) to pass back to `datasources_get_tables`/`_get_columns`, and `label` is its last segment. On a two-level engine (`catalog.schema`, `project.dataset`) two entries can share a label and differ only by their outer segment, which is what the array form exists for; `schemas` repeats the labels for pre-087 clients and is kept for one release. `truncated: true` means the 2000-entry cap dropped some (on MySQL catalog routing the walk would otherwise span every database the server grants). On MySQL the databases arrive as JDBC catalogs (Connector/J defaults), so the listing reads them from `getCatalogs()` — the same vocabulary `datasources_get_tables` routes through; system schemas/databases (`information_schema`, `mysql`, `performance_schema`, `sys` on MySQL) are excluded on every dialect. **An empty list is a valid result** — a datasource with no namespace dimension (SQLite) has none to list. See [Datasources §7A](/docs/datasources.md#7a-schema-introspection). A connection failure against the datasource is the catalogued `pipeline.execution.datasource_unreachable` `isError` envelope — the same rule applies to §6.2.17/§6.2.18.

**Permission:** `datasource.introspect` — introspection is reading (ratified 2026-09-20): every role holds it, the promoter included (C1). It opens a live connection against the datasource but returns metadata, never rows.

#### 6.2.17 `datasources_get_tables`

List a datasource's tables and views.

```json
{
  "name": "datasources_get_tables",
  "description": "List the tables and views of a registered datasource by reading its live JDBC metadata. The listing spans namespaces — pass each table's reported `namespace` array to datasources_get_columns. A table carries `facts` when agents have recorded table-level learned facts on it (grain, sampling, window, a caveat) — read them before probing; a fact marked stale or needs_review is a warning, not a truth. For a LAKE datasource each table carries `partition_column`: the registered partition key, or `null` when none is registered. A compatible filter on a partition key can skip files; missing registration does not establish file count or rule out Parquet row-group skipping and column projection. Read-only, for pipeline authoring.",
  "inputSchema": {
    "type": "object",
    "required": ["name"],
    "properties": {
      "name": {"type": "string", "description": "Datasource name."},
      "namespace": {
        "type": "array",
        "items": {"type": "string"},
        "description": "Optional namespace filter, outermost first, as returned by datasources_get_schemas. An unknown namespace matches nothing."
      },
      "schema": {"type": "string", "description": "Optional single-level filter; accepts the dotted 'catalog.schema' form. Superseded by namespace."}
    }
  }
}
```

Returns: `{"tables": [{"namespace": [...], "schema", "name", "type", "remarks"?}], "truncated": bool}` — `namespace` is the containing path outermost-first and `schema` its last segment (kept for one release so a pre-087 client reads what it always read); `type` is the driver's raw JDBC table type (`TABLE`, `VIEW`, `BASE TABLE`, ...); `remarks` is the engine-stored table comment, omitted when the driver/database has none; `facts` (118) is the table's TABLE-GRAIN learned facts — refs with no column: `grain`, `window`, `sampling`, a table-level `caveat` — in the §7A.5 shape, omitted when there are none. For a LAKE datasource each table entry also carries `partition_column` (126): the registered partition column's name, or `null` when none is registered — the same registry value §6.2.33's stats payload reports, so the listing and the stats read cannot disagree; a compatible predicate on a partition key can skip files. Missing registration does not establish physical layout or file count; Parquet row-group skipping and column projection may still reduce reads. A pushed filter alone does not prove skipped files or bytes. Non-lake dialects carry no such key. Only a COMPLETE listing (no `namespace`/`schema` filter, not truncated) runs the §7A.5 drift mark for a fact whose table is no longer listed; a filtered listing proves nothing about what it did not list. The listing is capped at **2000 tables**; `truncated: true` means the cap dropped some. The `namespace` and `schema` filters are exact-match, not LIKE patterns, and a namespace deeper than the dialect's own matches nothing. Without a filter the listing **spans namespaces** — pass each table's reported `namespace` to `datasources_get_columns` (there, no namespace argument means the connection's current one only, and a datasource reporting **none** fails with the catalogued `pipeline.execution.parameter_required` rather than merging same-named tables' columns — the merge hazard lives in `datasources_get_columns` alone; a tables listing carries each row's own namespace and cannot merge, so it deliberately has no such guard and works unfiltered on those datasources too).

**Permission:** `datasource.introspect` — introspection is reading (ratified 2026-09-20): every role holds it, the promoter included (C1). It opens a live connection against the datasource but returns metadata, never rows.

#### 6.2.18 `datasources_get_columns`

List one table's columns with canonical types.

```json
{
  "name": "datasources_get_columns",
  "description": "List one table's columns with canonical types, read from the datasource's live JDBC metadata. Pass the table name exactly as datasources_get_tables returned it, and its `namespace` array with it. Without a namespace only the connection's current one is read; if the datasource reports none, an explicit namespace is required (list them with datasources_get_schemas). On a two-level engine an unqualified read can merge same-named tables from different catalogs, which is why the namespace is worth passing. Each column carries `facts` when agents have recorded learned facts on it — a unit, a time zone, what a coded value means, a join, a caveat — with trust and evidence: read them before probing, and treat stale or needs_review as a warning to re-verify, then record the superseding fact. An unknown table is refused as datasource.table_not_found, naming the nearest listed table when one is close; a LAKE datasource answers datasource.lake_table_not_found for a table its registry does not carry. Read-only, for pipeline authoring.",
  "inputSchema": {
    "type": "object",
    "required": ["name", "table"],
    "properties": {
      "name": {"type": "string", "description": "Datasource name."},
      "table": {"type": "string", "description": "Table name as returned by datasources_get_tables."},
      "namespace": {
        "type": "array",
        "items": {"type": "string"},
        "description": "The table's namespace, outermost first, as datasources_get_tables reported it. An unknown namespace matches nothing."
      },
      "schema": {"type": "string", "description": "Optional single-level filter; accepts the dotted 'catalog.schema' form. Superseded by namespace."}
    }
  }
}
```

Returns: array of `{"name", "type", "precision", "scale", "nullable", "source_type", "warnings", "remarks"}` — `type` is the canonical Type System type, `source_type` the driver's own type name, `warnings` the ingress mapper's warning messages (empty when the mapping was clean), `remarks` the engine-stored column comment (omitted when there is none); `precision`/`scale`/`nullable`/`remarks` are omitted when the metadata does not report them; `facts` (118) is the column's learned facts in the §7A.5 shape — every fact with a ref on this column, a two-column `join` fact on both — omitted when there are none. This is the read that runs the §7A.5 drift check: each fact's fingerprint is recomputed from the columns just listed, and a demotion (`needs_review`, `stale`) is written back to the row before it is served. An unknown table is refused as **`datasource.table_not_found`** (123 §A): the message asserts "does not exist" on complete-catalog dialects and "does not exist, or the datasource's credentials cannot see it" on privilege-filtered ones, and names the nearest listed table when one is close — an existing table with zero readable columns still returns an empty list. `table` and `schema` are exact-match identifiers — JDBC metadata name matching is case-sensitive, `_`/`%` are not wildcards; pass the name `datasources_get_tables` returned. System-schema rows are excluded; without a `schema` argument the read defaults to the connection's current schema (routed per dialect, [Datasources §7A](/docs/datasources.md#7a-schema-introspection)) so same-named tables in different schemas cannot merge their columns — and a datasource that reports **no current schema** makes that default impossible, so the call fails with the catalogued `pipeline.execution.parameter_required` instead of silently merging (`datasources_get_schemas` lists the schemas to pass; schemaless datasources such as SQLite are the exception — there is nothing to merge).

**Permission:** `datasource.introspect` — introspection is reading (ratified 2026-09-20): every role holds it, the promoter included (C1). It opens a live connection against the datasource but returns metadata, never rows.

#### 6.2.18a The learned-fact block on introspection responses (118)

The three introspection tools above, `datasources_list` (126) and `datasources_get` carry the facts agents recorded about the objects they return — and, since 136 §A, the workspace's rules under a second key, `definitions`, on the listing and on `datasources_get` (§6.2.10/§6.2.11) ([learned-semantic-layer design](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md) D-S7, §7.2) — INLINE, so the fact is where the agent is already looking and there is no separate memory to forget to query. The REST twins (`GET /api/v1/datasources/{name}`, `.../tables`, `.../tables/{t}/columns`, [REST API §9.7A](/docs/rest-api.md#97a-learned-facts-on-the-introspection-endpoints-118)) carry the identical block from the same code. Each fact renders as:

```jsonc
{
  "id": "…",
  "scope": "DATASOURCE",
  "kind": "unit",
  "fact": "value is already in the unit named by unit_col — never tenths",
  "trust": "observed",
  "drift": "column X no longer exists",
  "evidence_summary": "unit=°C, value=21.4 | unit=mm, value=0.8",
  "recorded_via": "mcp",
  "recorded_at": "2026-09-11T10:15:00Z",
  "from_this_workspace": true,
  "source_pipeline": {"id": "…", "name": "finance/revenue"},
  "conflict": true
}
```

`drift`, `evidence_summary`, `source_pipeline` and `conflict` are omitted-when-absent. A WORKSPACE-scope fact on the `definitions` block (and every WORKSPACE row of `semantics_list`, §6.2.38) also carries **`implemented_by`** (7e): `[{template_id, version}]`, the live template versions citing it as the script implementing it, under the key's template lens — `[]` when none; a DATASOURCE fact never carries the key, because nothing can cite it ([Templates §3.4](/docs/templates.md#34-implements-and-drift)). `trust` is `asserted` (no evidence) / `observed` (evidence ran at record time) / `verified` (a human confirmed) / `needs_review` (the table's column set changed around a still-resolving ref) / `stale` (a referenced column or table no longer exists); `retired` facts are never served here (`semantics_list` with `include_retired` lists them). **The drift check runs at read (design §6):** `datasources_get_columns` recomputes each fact's per-table fingerprint from the columns it just read and a complete `datasources_get_tables` listing checks each fact's tables — a demotion is written back to the row (idempotent, one-way; the alternative is serving a mark the server computed and then forgot). Nothing re-maps: a rename and a "drop + unrelated add" are indistinguishable to a machine, so a `stale` fact stays beside the current columns until an agent records the superseding fact with evidence (`semantics_record` with `supersedes`). **Conflicts coexist (D-S5):** two live facts of one kind on the same refs are both served, each `conflict: true`; the reader decides. **Provenance stops at the reader's visibility (D-S9):** a DATASOURCE fact recorded from another workspace arrives with its evidence and trust and `from_this_workspace: false`; `source_pipeline` is present only when the reader's workspace can read that pipeline — the same `findById(workspace, id)` predicate every pipeline read uses.

#### 6.2.19 `datasources_preview_rows`

Preview up to `limit` rows of one table's data — the counterpart to `datasources_get_columns`.

```json
{
  "name": "datasources_preview_rows",
  "description": "Preview up to `limit` rows of one table's data, read live from the datasource. The counterpart to datasources_get_columns: this shows the DATA, that shows the shape. Without order_by the top-N is engine-arbitrary; pass order_by to see a chosen end of the data, e.g. direction DESC for the newest or largest rows. Read-only (SELECT); readonly datasources are valid targets. Values arrive wire-encoded: BIGINTEGER and BIGDECIMAL as strings, temporal as fixed-width ISO forms. An unknown table is refused as datasource.table_not_found naming the nearest listed table; a table the datasource's credentials cannot read is datasource.table_forbidden.",
  "inputSchema": {
    "type": "object",
    "required": ["name", "table"],
    "properties": {
      "name": {"type": "string", "description": "Datasource name."},
      "table": {"type": "string", "description": "Table name exactly as datasources_get_tables returned it."},
      "schema": {"type": "string", "description": "Optional schema qualifier. Omitted means the connection's current schema."},
      "order_by": {
        "type": "array",
        "description": "Sort terms applied in order. Each is an object with a column and a direction, never a free SQL string.",
        "items": {
          "type": "object",
          "required": ["column"],
          "properties": {
            "column": {"type": "string", "description": "Column name exactly as datasources_get_columns returned it."},
            "direction": {"type": "string", "enum": ["ASC", "DESC"], "default": "ASC"}
          }
        }
      },
      "limit": {"type": "integer", "default": 50, "minimum": 1, "maximum": 50}
    }
  }
}
```

Returns: `{"datasource", "table", "schema"?, "columns": [{"name", "type"}], "rows": [{column: value, ...}], "row_count", "truncated"}` — values wire-encoded per the result-cursor rules (`BIGINTEGER`/`BIGDECIMAL` as strings, temporal fixed-width ISO, `BINARY` base64). The server builds the ENTIRE statement and quotes every identifier with the dialect's quote character (backtick on MySQL, `[...]` on MSSQL, doubled `"` elsewhere) — the agent supplies identifiers only, and a blank identifier is `-32602`. `order_by` entries are `{column, direction}` objects; a free `"col DESC"` string is refused, not parsed. Without `order_by` the top-N is engine-arbitrary. The cap is applied in the dialect's own syntax (`LIMIT`, Oracle `FETCH FIRST n ROWS ONLY`, MSSQL `TOP (n)`) AND as JDBC `maxRows`/`fetchSize`. Readonly datasources are valid targets — the readonly refusal covers write-shaped node uses, and this is a SELECT. `tempdb` can never be a target: a datasource of that name cannot be registered (contract §4.8). The table is resolved before any statement runs (123 §A): an unknown table is **`datasource.table_not_found`**, naming the nearest listed table when one is close; a table the datasource's credentials cannot read — the SELECT refused with a permission SQLSTATE — is **`403 datasource.table_forbidden`**. A connection failure is the catalogued `pipeline.execution.datasource_unreachable`; any other refused statement is the catalogued `pipeline.node.query_execution_failed` carrying the bounded driver message.

**Permission:** `datasource.preview_rows` — this returns arbitrary customer ROW DATA, not metadata; a role without it — viewer, promoter — does not acquire that reach (037 F).

#### 6.2.20 `pipelines_execute_node`

Runs ONE pipeline node's rendered SQL against its own datasource — a debug query, not an execution.

```json
{
  "name": "pipelines_execute_node",
  "description": "Runs ONE pipeline node's rendered SQL against its own datasource and returns up to 50 decoded rows — a debug query for testing a node in isolation, NOT a pipeline execution. DML and DDL nodes execute FOR REAL against the datasource, leaving no execution history or trace. No ancestors run and no tempdb exists: a node whose source is tempdb is refused, and so is a SCRIPT node (use templates_evaluate). Parameters bind through the pipeline's declarations; unsupplied required parameters fall back to sample values and the response names them in sampled_parameters. Absent version runs the DRAFT if one exists, else the current released version; the response states which version and status ran. A draft whose pinned draft template was updated after this key's last templates_render of it is refused pipeline.execution.template_unrendered — render, then run.",
  "inputSchema": {
    "type": "object",
    "required": ["pipeline_id", "node_id"],
    "properties": {
      "pipeline_id": {"type": "string", "format": "uuid"},
      "node_id": {"type": "string", "description": "Node id within the pipeline body."},
      "version": {"type": "integer", "minimum": 1, "description": "Pipeline version to read. Omitted: the DRAFT if one exists, else the current released version."},
      "parameters": {
        "type": "object",
        "description": "Values for the pipeline's declared parameters, keyed by name. Types follow the declarations (BIGINTEGER and BIGDECIMAL as strings).",
        "additionalProperties": true
      }
    }
  }
}
```

Returns: `{"node_id", "node_type", "datasource", "version", "status", "sql", "sampled_parameters"?, "elapsed_ms"}` plus, for DQL nodes, `{"columns": [{"name", "type"}], "rows": [{column: value, ...}], "row_count", "truncated"}` (capped at 50 rows like `datasources_preview_rows`), or for DML/DDL nodes `{"affected_rows"}` — **DML/DDL execute for real**, with no execution row, no SSE and no idempotency record (ratified, 037 §A). `sql` is the rendered template output in its `:name` form; execution binds those names as statement parameters. `status` is the run version's lifecycle status (`DRAFT`/`RELEASED`), so the agent never infers which body it ran.

Refusals, all before anything runs: an unknown node id → `pipeline.node.not_found` (404 semantics); a node whose `source` is `tempdb` → `pipeline.node.standalone_execution_refused` with `details.reason = tempdb_source` (the staging database exists only inside a full execution — use `pipelines_execute`); a PIPELINE node → the same code with `details.reason = pipeline_node` (it runs a child pipeline, not SQL); a missing pinned template → `pipeline.node.template_not_found`; a render failure → `pipeline.node.template_render_failed`; a supplied parameter failing §6.3 coercion → `pipeline.execution.invalid_parameter_type` naming every failure; a rendered `:name` the pipeline does not declare → `pipeline.node.sql_parameter_missing` (042). A DML/DDL node against a readonly datasource is refused with `pipeline.node.datasource_readonly`; a datasource invisible to the key's workspace resolves as `datasource.not_found`, and an unreachable datasource as `pipeline.execution.datasource_unreachable`; a refused statement is `pipeline.node.query_execution_failed`.

**Permission:** `pipeline.execute_node` — this runs real SQL and returns arbitrary customer row data; the same 037 F reasoning as `datasources_preview_rows`.

#### 6.2.21 `templates_used_by`

Which pipelines pin a given template version — the reverse arrow of a node's `{id, version}` template pin (040).

```json
{
  "name": "templates_used_by",
  "description": "Which pipelines, parameter sets AND visualizations pin a given template version in their working version (the draft when unreleased edits exist, else the latest released). Returns one reference per node — pipeline name and id, node id, and the pipeline version carrying the pin — plus the distinct pipeline count, and one row per pinning parameter set (#194: set name and id, the parameter, the set version carrying the pin), and one row per pinning visualization (#320: its `transform.template` pin — visualization name and id, the visualization version carrying the pin and its status). Use it before editing or retiring a template version to see who you would affect. It does not answer 'is it safe to delete' (that scan includes historical pipeline versions and lives in the delete refusal), and it never changes anything. A promoter's key sees only RELEASED templates newer than the promotion target's (the promoter lens); every other template resolves as not-found, and pinning pipelines, sets or visualizations it cannot see are left out of the answer.",
  "inputSchema": {
    "type": "object",
    "required": ["id", "version"],
    "properties": {
      "id": {"type": "string", "description": "Template id."},
      "version": {"type": "integer", "description": "The pinned version to look for."}
    },
    "additionalProperties": false
  }
}
```

Returns: `{"template": {"id", "version"}, "scan": "working_version", "pipeline_count", "references": [{"pipeline", "pipeline_id", "node_id", "pipeline_version", "pipeline_version_status"}], "parameter_set_references": [{"parameter_set", "parameter_set_id", "parameter", "set_version", "set_version_status"}], "visualization_references": [{"visualization", "visualization_id", "visualization_version", "visualization_version_status"}]}` — since #320 a visualization whose `transform.template` pins this version is a real reference too (working-version scan, under the caller's visualization lens: a hidden visualization is never named, and a narrowing view sees RELEASED rows only) — one row per pinning NODE (pipelines) and per pinning PARAMETER (sets), so a pipeline with two nodes on the same version appears twice and `pipeline_count` stays the honest distinct count. Since #194 lane D the answer covers PARAMETER SETS too (the record's §8.4): a set whose selector pins this template version is a real reference, listed with the set name, the parameter and the set version carrying the pin. The scan reads each pipeline's **working version** (draft-if-exists, versioning §7), so a draft that just adopted the pin is already counted — the "who do I notify" answer an author needs before editing a template ([Templates §5.4](/docs/templates.md#54-used-by-the-reverse-arrow-v19-040)). `version` is required and never clamped (the [D2 rule](/docs/templates.md#54-used-by-the-reverse-arrow-v19-040): the question is per version). The delete-safety question — who pins ANY version, in ANY pipeline version ever, and since lane D in any parameter-set version ever — is a different scan and surfaces as the `template.in_use` delete refusal on the REST surface, not here.

An unknown template id is the catalogued `template.not_found`; a known id with no such version is the same code with a `version` detail. **Permission:** `template.read` (040 D7) — reference structure a workspace reader may already see by reading the pipelines themselves; no customer row data.

#### 6.2.22 (removed) — no datasource writes on this surface

**No credential travels through an agent.** People add datasources in the UI; an operator uses `POST /api/v1/datasources` ([Datasources §3.1](/docs/datasources.md#31-json-structure-request--post-apiv1datasources)) or the bootstrap file ([Datasources §8A](/docs/datasources.md#8a-bootstrap-registration-config-declared-datasources)); agents use the datasources that already exist, **by name**. Creating, editing and deleting a datasource are UI/REST-only, without exception.

068 shipped a `datasources_create` tool here and wrote the hazard into its own description: a secret sent through a tool call transits the agent's context, its transcript and whatever logging the client does, so "prefer the UI for a real credential" was an instruction to the model. 094 (owner ruling 4) decided that hazard is not documentable away — the tool's own description is not a control — and removed it. The surface went 31 → 30 tools; this section number is kept so §6.2.23 onward do not shift.

What stays is every datasource tool that needs no credential: `datasources_list`, `datasources_get`, `datasources_test`, `datasources_get_schemas`, `datasources_get_tables`, `datasources_get_columns`, `datasources_get_table_stats`, `datasources_preview_rows` and `sql_probe`. An agent can therefore still discover a connection, confirm it works, read its shape and statistics, probe a SELECT and preview rows — everything authoring a pipeline needs — without one ever being handed a password. The same reasoning is why there is no `api_keys_create`: see the §6.2.23 preamble.

An agent that needs a datasource it cannot find should **ask the person** to add it in the UI, then read it back with `datasources_list`.

#### 6.2.23 `endpoints_create`

Publish a released pipeline as a `GET` endpoint at `/api/<category>/<version>/<path…>` (R-EP5; [REST API §19](/docs/rest-api.md#19-published-endpoints)). Calls the same service `POST /api/v1/endpoints` calls — the same read-only rule, the same reserved-category refusal, the same ambiguity refusal, the same audit (049's rule: two entry points, one validated path).

```json
{
  "name": "endpoints_create",
  "description": "Publish a released pipeline as a GET endpoint at /api/<category>/<version>/<path>. The category is your namespace (a business domain, a team); categories matching v<number> and the literal 'api' are reserved to the product and refused with endpoint.path_reserved. The version is one free-form segment (v1 by convention); path variables come after it. The pipeline must have a RELEASED version and must be side-effect-free: every node DQL into tempdb or the caller, a DML/DDL node whose source is tempdb, transitively through PIPELINE nodes. A DML/DDL node against a registered datasource, or a DQL node writing back to a datasource, is refused with endpoint.pipeline_not_readonly naming the node and the datasource — that rule is what makes serving over GET safe, since GET is retried, preloaded and crawled. path is 3-10 segments, each a literal [a-z0-9][a-z0-9_.-]{0,63} or a {variable} naming a declared parameter; remaining parameters come from the query string. A leading /api prefix is stripped, not refused. A path that could match the same URL as an existing one is refused (endpoint.path_conflict) rather than resolved by precedence. Calling the endpoint needs an API key bound to it — create one with the api_caller role and bind it over REST or in the UI (auth.md §7.7); an endpoint bound to no key is served to no one, and an MCP key never reaches REST at all (it acts over /mcp only).",
  "inputSchema": {
    "type": "object",
    "required": [
      "path",
      "pipeline"
    ],
    "additionalProperties": false,
    "properties": {
      "path": {
        "type": "string",
        "description": "e.g. /finance/v1/revenue/{region} — category, version, then the path; no /api prefix (one is stripped, not refused), no trailing slash."
      },
      "pipeline": {
        "type": "string",
        "description": "The pipeline NAME. It must have a released version."
      },
      "timeout_seconds": {
        "type": "integer",
        "description": "Clamped by datapipelines.endpoints.timeout-min-seconds/max-seconds. On timeout the endpoint answers 202 and the execution keeps running."
      },
      "description": {
        "type": "string"
      }
    }
  }
}
```

Returns the endpoint's wire shape: `path`, `pipeline` (by NAME), `timeout_seconds`, `description`, `enabled`, `path_variables` and the servable `url`. A legacy path (listed flagged by §6.2.24) resolves as **not-found** here — the single read answers the valid shape only.

#### 6.2.24 `endpoints_list`

The published endpoints of the key's pinned workspace.

```json
{
  "name": "endpoints_list",
  "description": "List the published endpoints of the key's workspace: path, pipeline name, timeout, whether it is enabled, and the path variables it binds. A disabled endpoint answers 404 exactly like an unpublished one, so this listing is the only way to see that it exists. A promoter's key lists only the endpoints of pipelines it can see (the promoter lens: released, newer than the promotion target's). Legacy rows (#274) are listed last with \"legacy\": true and their \"reason\": a stored path saved before the current grammar that is retired — never served, never a conflict partner; endpoints_delete removes one.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {}
  }
}
```

Returns `{endpoints: [...]}` in the shape §6.2.23 returns. Legacy rows (#274) are listed last, flagged: a stored path saved before the current grammar carries `"legacy": true`, its `"reason"` (the grammar's own refusal, bounded), `"enabled"` and the usual `path`/`pipeline`/`url` — no `path_variables`, because there is no parse. A legacy row never serves, never blocks a publish, and `endpoints_delete` removes it.

#### 6.2.25 `endpoints_get`

One published endpoint, by its path PATTERN.

```json
{
  "name": "endpoints_get",
  "description": "One published endpoint by its path (the pattern, not a request URL — '/finance/v1/revenue/{region}'). For a promoter's key an endpoint over a pipeline it cannot see resolves as not-found.",
  "inputSchema": {
    "type": "object",
    "required": [
      "path"
    ],
    "additionalProperties": false,
    "properties": {
      "path": {
        "type": "string",
        "description": "The published path PATTERN, e.g. /finance/v1/revenue/{region}."
      }
    }
  }
}
```

Returns the §6.2.23 shape, or `endpoint.not_found`.

#### 6.2.26 `endpoints_delete`

Unpublish an endpoint. The pipeline is untouched; key bindings on that node are not removed, since a node may still carry other endpoints beneath it.

```json
{
  "name": "endpoints_delete",
  "description": "Unpublish an endpoint by its path. The pipeline is untouched — only the URL stops answering. Key bindings on that path are NOT removed: they describe a node of the tree, which may still carry other endpoints beneath it. This is also the fix for a LEGACY row (#274) — a retired path listed with \"legacy\": true never serves under the current grammar, and deleting it removes the row.",
  "inputSchema": {
    "type": "object",
    "required": [
      "path"
    ],
    "additionalProperties": false,
    "properties": {
      "path": {
        "type": "string",
        "description": "The published path PATTERN, e.g. /finance/v1/revenue/{region}."
      }
    }
  }
}
```

Returns `{path, deleted: true}`, or `endpoint.not_found`.

**Permission:** `endpoint.unpublish`. **Mutating** — declared `mutating` in the tool catalog, so every call writes `mcp.tool.called` **and** `mcp.tool.write` at the dispatcher's single audit choke point (§6.3).

#### 6.2.23 `calculators_list`

The catalog of calculator kinds a `CALCULATOR` node can evaluate ([Calculators §2](/docs/calculators.md), [Pipeline Contract §4.10](/docs/pipeline-contract.md#410-json-structure-calculator-node)). The tool an agent calls **before** authoring a calculator node: a `kind` and its input names are the two things it cannot guess, and getting them from a 400 one at a time is a slow way to learn a fixed list.

```json
{
  "name": "calculators_list",
  "description": "The catalog of calculator kinds a CALCULATOR node can evaluate: every kind with its typed inputs (name, type, required, whether it takes a JSON array, and its default when optional), its output type (or, for a multi-output kind, the named `outputs` set a node maps through `context_keys`), one worked example, and `phrases` — the everyday phrases the kind answers. Call this before authoring a CALCULATOR node — the kind names and input names are not guessable — and match the question's words against `phrases` before you pick a kind: a relative time phrase ('last quarter', 'month to date') is resolved by that lookup, never by interpreting it yourself. Also returns the Context keys every pipeline can reference without declaring anything: the deployment's org_* values and the platform keys current_date, current_timestamp and execution_id. Read-only.",
  "inputSchema": {
    "type": "object",
    "properties": {},
    "additionalProperties": false
  }
}
```

**Permission:** `calculator.read` — and read in the strongest sense the surface has: the answer is a property of the BUILD, identical for every caller, every key and every workspace. No workspace scoping applies because there is no workspace data in it.

**Response:** `kinds` (each with `kind`, `display_name`, `description`, `phrases`, `inputs`, `output`, `example`), `count`, `context_keys` (`org` names and `platform` name/type pairs), and `docs` pointing at the catalog page. A **single-output** kind carries `output` (the wire type, or `"ANY"`) and no `outputs` key at all; a **multi-output** kind (121) carries `"output": null` and `outputs` — the named set `[{name, type, description}]` a node maps through `context_keys` ([Pipeline Contract §4.10](/docs/pipeline-contract.md#410-json-structure-calculator-node)), every name mapped or the save is refused. `phrases` lists the everyday phrases the kind answers — match the question's words against them before picking a kind (the same lookup the skill's calculator rule teaches). An input carries `list: true` only when it takes a JSON array, and `default` only when it is optional — the absent keys carry the same information as `false`/`null` would, without spending an agent's context window on eighty of them.

#### 6.2.24 `calculators_get`

One kind's full definition — the same entry `calculators_list` returns, for a caller that already knows the name.

```json
{
  "name": "calculators_get",
  "description": "One calculator kind's full definition: display name, description, typed inputs, output type (or the named `outputs` set of a multi-output kind), a worked example and `phrases` — the everyday phrases the kind answers, which you match the question's words against before picking a kind. Use it when you know the kind and need its exact input names and types. An unknown kind is refused with the catalogued names in the error detail. Read-only.",
  "inputSchema": {
    "type": "object",
    "required": ["kind"],
    "properties": {
      "kind": {"type": "string", "description": "The kind name, e.g. fiscal_quarter."}
    },
    "additionalProperties": false
  }
}
```

**Permission:** `calculator.read`.

**Errors:** an unknown kind is `pipeline.validation.calculator_unknown` with `known_kinds` in the detail — deliberately the SAME code a rejected `pipelines_create` returns for a bad `kind`, so an agent sees one fact about the world rather than two unrelated failures.

#### 6.2.29 `lake_tables_register`

Register one table in a LAKE datasource's catalog — the dp-lake registry ([metadata-db §4.15](/docs/metadata-db.md#415-lake_tables), [Datasources §4.1](/docs/datasources.md)). A LAKE datasource's tables are exactly the registered rows: the engine cannot list a bucket, so this catalog is what introspection and query resolution read. Mirrors `POST /api/v1/datasources/{name}/tables` ([REST §9.8](/docs/rest-api.md#98-lake-tables-the-dp-lake-catalog)) — the SAME application service, so validation, the duplicate refusal and the pool invalidation are identical on both surfaces.

```json
{
  "name": "lake_tables_register",
  "description": "Register one table in a LAKE datasource's catalog (the dp-lake registry). Mirrors POST /api/v1/datasources/{name}/tables: namespace (array of segments or the dotted 'acme.analytics' shorthand), table, format (parquet | iceberg) and location are required; partition_column is optional. The location is s3://bucket/prefix/ (parquet: a directory or glob; iceberg: the table's CURRENT metadata file, e.g. s3://bucket/table/metadata/00042-<uuid>.metadata.json — DuckDB 1.5.5 cannot scan a pyiceberg table by its root, so register the file, and re-register it when the table commits) or a file:// path — no other scheme, and no quotes, backslashes, whitespace or control characters (it is interpolated into the engine's CREATE VIEW, so the refusal is total). Segments follow the pipeline/template segment grammar without dots. Registering an already-registered (namespace, table) is the 409 datasource.lake_table_duplicate; a non-LAKE datasource is refused. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": [
      "name",
      "namespace",
      "table",
      "format",
      "location"
    ],
    "additionalProperties": false,
    "properties": {
      "name": {
        "type": "string",
        "description": "Datasource name. A LAKE datasource visible in the key's pinned workspace."
      },
      "namespace": {
        "description": "Namespace — a segments array or the dotted shorthand. 1-9 segments, the segment grammar without dots.",
        "anyOf": [
          {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          {
            "type": "string"
          }
        ]
      },
      "table": {
        "type": "string",
        "description": "The table's name — one segment of the same grammar, e.g. events_by_day."
      },
      "format": {
        "type": "string",
        "enum": [
          "parquet",
          "iceberg"
        ]
      },
      "location": {
        "type": "string",
        "description": "s3://bucket/prefix/ (parquet dir/glob; iceberg: the current metadata file, not the table root) or file:// path. Nothing else; no injection chars."
      },
      "partition_column": {
        "type": "string",
        "description": "Optional. The hive-style partition column, e.g. event_date."
      }
    }
  }
}
```

**Permission:** `lake_table.manage` — the datasource-mutation floor ([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)). Mutating a GLOBAL datasource's registry additionally requires admin, a workspaces D8 rule inside the shared service rather than a scope.

**Mutating.** Declared `mutating` in the tool catalog: every call writes `mcp.tool.called` **and** `mcp.tool.write` at the dispatcher's single audit choke point (§6.3).

**Errors:** `datasource.not_found` (unknown or invisible datasource), `datasource.validation.lake_dialect_required` (not a LAKE datasource), `lake_namespace_invalid` / `lake_name_invalid` / `lake_format_invalid` / `lake_location_invalid` (the grammar and the injection refusal), `datasource.lake_table_duplicate` (409 — the triple is taken).

**Response:** the stored row — `namespace`, `name`, `qualified_name`, `format`, `location`, `partition_column`, `registered_at`.

#### 6.2.30 `lake_tables_import`

Bulk-register from a `manifest.json` `tables[]` block (the manifest shape documented in the description below), inline or by URL. Mirrors `POST /api/v1/datasources/{name}/tables/import`. A manifest URL is fetched **server-side, and only from the datasource's own bucket/endpoint** — derived from its declared `dialect.endpoint` / `catalog.ref`, or AWS S3 when neither is set; anything else is refused. There is no arbitrary URL fetch (SSRF).

```json
{
  "name": "lake_tables_import",
  "description": "Bulk-register lake tables from a manifest's `tables[]` shape. Mirrors POST /api/v1/datasources/{name}/tables/import: pass EITHER tables (an array of {name, format, location|path, partition_column?, namespace?}, with publish_prefix for relative paths and an optional shared namespace) OR manifest_url. A manifest URL is fetched server-side ONLY from the datasource's own endpoint/bucket — derived from its declared dialect.endpoint / catalog.ref, or AWS S3 when neither is set; anything else is refused with datasource.validation.lake_manifest_url_forbidden (no arbitrary URL fetch — SSRF). Import is idempotent: already-registered tables are reported in already_registered, not errors. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": [
      "name"
    ],
    "additionalProperties": false,
    "properties": {
      "name": {
        "type": "string",
        "description": "Datasource name. A LAKE datasource visible in the key's pinned workspace."
      },
      "tables": {
        "type": "array",
        "description": "Inline form: manifest entries {name, format, location|path, partition_column?, namespace?}.",
        "items": {
          "type": "object"
        }
      },
      "namespace": {
        "description": "Shared namespace applied to entries that carry none — an array of segments or the dotted shorthand.",
        "anyOf": [
          {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          {
            "type": "string"
          }
        ]
      },
      "publish_prefix": {
        "type": "string",
        "description": "Base URI resolving relative entry paths, e.g. s3://bucket/lake/v1."
      },
      "manifest_url": {
        "type": "string",
        "description": "URL of a manifest.json. Fetched ONLY from the datasource's own endpoint/bucket or AWS S3; else refused."
      }
    }
  }
}
```

**Permission:** `lake_table.manage`. **Mutating** — same audit pair as `lake_tables_register`.

**Errors:** the register set, plus `datasource.validation.lake_manifest_url_forbidden` (a URL outside the datasource's own roots) and `pipeline.execution.datasource_unreachable` (502 — the manifest could not be fetched from the datasource's own storage).

**Response:** `registered` (the stored rows), `registered_count`, `already_registered` (dotted qualified names skipped as already present — import is idempotent, so bootstrap can re-run it), `already_registered_count`.

#### 6.2.31 `lake_tables_unregister`

Unregister one table. The objects in the bucket are untouched — the table stops being served by the datasource. Mirrors `DELETE /api/v1/datasources/{name}/tables/{namespace}/{table}`.

```json
{
  "name": "lake_tables_unregister",
  "description": "Unregister one table from a LAKE datasource's catalog. Mirrors DELETE /api/v1/datasources/{name}/tables/{namespace}/{table}: the objects in the bucket are untouched — the table stops being served by the datasource. Unregistering a table that is not registered is the 404 datasource.lake_table_not_found, never a silent no-op. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": [
      "name",
      "namespace",
      "table"
    ],
    "additionalProperties": false,
    "properties": {
      "name": {
        "type": "string",
        "description": "Datasource name. A LAKE datasource visible in the key's pinned workspace."
      },
      "namespace": {
        "description": "The table's namespace, outermost first — an array of segments or the dotted shorthand ('acme.analytics').",
        "anyOf": [
          {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          {
            "type": "string"
          }
        ]
      },
      "table": {
        "type": "string",
        "description": "The table to unregister."
      }
    }
  }
}
```

**Permission:** `lake_table.manage`. **Mutating** — same audit pair as `lake_tables_register`.

**Errors:** `datasource.not_found`, `datasource.validation.lake_dialect_required`, the grammar codes for a malformed `namespace`/`table`, and `datasource.lake_table_not_found` (404 — an absent triple, never a silent no-op).

**Response:** `{datasource, table, deleted: true}` with `table` the dotted qualified name.

#### 6.2.32 `templates_purge_draft`

Hard-delete a template that has NEVER been released — the one lifecycle verb an agent gets (101's bounded D61/D62 self-service fraction; humans release, and humans discard releases — D4/D57 are untouched).

```json
{
  "name": "templates_purge_draft",
  "description": "Hard-delete a template that has NEVER been released: the only version is a DRAFT, created by this key's user, and pinned by nothing — no pipeline version anywhere, draft or released, no parameter-set version either (#194), and no visualization version (#320) may reference any version of it (the refusal names the pinning pipelines, sets or visualizations your view admits; templates_used_by answers the working-version scan if you need to inspect them). The sole-draft purge takes the entity row with it. A template holding any RELEASED or discarded version, another user's draft, or a pinned draft is refused — humans release and humans discard releases; an agent's own draft that should not exist is what this verb removes. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": ["id"],
    "additionalProperties": false,
    "properties": {
      "id": {"type": "string", "description": "Template id."}
    }
  }
}
```

Returns: `{"id", "purged": true}`.

Admission is exact: the ONLY version is a DRAFT, created by THIS key's user (templates record a creator user, never a key — "this key created it" degrades to "this key's user"), and NOTHING pins any version of it in ANY pipeline version ever — the delete guard's every-version-ever scan, not the working-version scan `templates_used_by` (§6.2.21) answers. A pinned draft is refused with `template.in_use` and `details.pinned_by` naming the pipelines to change first (`details.referencing_parameter_sets` for the parameter sets, #194 lane D; `details.referencing_visualizations` for the visualizations, #320 — the third arm, over every stored visualization version ever, discarded included); the scans read the WHOLE workspace whatever the caller's view (#300 — a hidden pin still refuses), and the refusal ECHOES only the pins the caller's view admits: when others exist outside the view it carries `details.pins_hidden: true` and says so in words — never a name, and never a count, of what the view hides; a template holding any RELEASED or discarded version is `template.version.last_release`; another user's draft is `auth.role_required` with `details.reason: "not_creator"` (the one authorization refusal since #215, owner ruling 2026-09-24); an unknown id is `template.not_found`. The sole-draft purge takes the entity row with it, and a concurrent edit between the guard reads and the delete is `template.version.conflict` — never a silent delete of someone else's save.

**Permission:** `template.version.manage`.

**Mutating.** Declared `mutating` in the tool catalog, so every call writes the §14 audit pair (`mcp.tool.called` and `mcp.tool.write`).

#### 6.2.33 `datasources_get_table_stats`

One table's catalog statistics — row estimate, indexes, per-column bounds — always from the engine's OWN catalog, never a scan ([Datasources §7C](/docs/datasources.md#7c-catalog-table-statistics)). The per-column min/max are the catalog's ESTIMATES (the planner's histogram ends from the last `ANALYZE`), not a scan — read them as bounds, and `sql_probe` for the exact bound. The fourth introspection tool, one step past the get_columns flow of §6.2.16–18.

```json
{
  "name": "datasources_get_table_stats",
  "description": "One table's catalog statistics: a row estimate, the index list (a lake table's partition column reports as the pseudo-index it is), and per-column distinct / null-fraction / min-max bounds — the min/max are the catalog's estimates, not a scan — probe for the exact bound. For a LAKE table the payload also carries partition_column: the registered partition column's name, or null when none is registered. This metadata does not establish file count or physical layout; Parquet row-group skipping and column projection may still reduce reads. A pushed READ_PARQUET filter alone does not prove skipped files or bytes. Every number comes from the engine's own catalog (pg_class, information_schema, parquet footers) — never a scan of the table, so this is safe at any table size. When a dialect holds no catalog stats the stat fields are null and stats_source is \"none\" — probe an explicit count with sql_probe if you need one. An unknown table is refused as datasource.table_not_found, naming the nearest listed table when one is close; a LAKE datasource answers datasource.lake_table_not_found for a table its registry does not carry. Read this before writing a predicate, then probe the query's access path and timing; an absent index alone does not establish scan cost.",
  "inputSchema": {
    "type": "object",
    "required": ["name", "table"],
    "properties": {
      "name": {"type": "string", "description": "Datasource name."},
      "table": {"type": "string", "description": "Table name exactly as datasources_get_tables returned it."},
      "namespace": {
        "type": "array",
        "items": {"type": "string"},
        "description": "The table's namespace, outermost first, as datasources_get_tables reported it. An unknown namespace matches nothing."
      }
    }
  }
}
```

Returns: `{row_estimate?, stats_as_of?, stats_source, indexes: [{name, columns, unique, primary, kind}], columns: [{name, n_distinct?, distinct_is_ratio, null_fraction?, min?, max?}]}` — the stat fields are omitted-when-null per the envelope convention (a missing `row_estimate` means "the catalog does not hold this", not zero), and `stats_source` names the catalog the numbers came from (`pg_class`, `information_schema.tables`, `sys.partitions`, `parquet_metadata`, …) or `"none"` when the dialect holds no catalog stats for the table. A LAKE table's registered partition column reports as the `{kind: "partition"}` pseudo-index — this describes the registered partition key, not an ordinary table index. The payload also carries `partition_column`, explicitly null when no key is registered. Neither a missing key nor a missing pseudo-index rules out Parquet data skipping or column projection. An unknown TABLE is **`datasource.table_not_found`** (the §7A resolution, 123 §A — naming the nearest listed table when one is close), so empty stats now mean exactly one thing: the table EXISTS and the catalog holds nothing for it. An unknown datasource is `datasource.not_found`, and a connection failure is `pipeline.execution.datasource_unreachable` — the same translations as §6.2.16–18.

**Permission:** `datasource.read` — the engine's own stored ESTIMATES about shape, never customer row data (the §6.2.21 reasoning), which is why it is `datasource.read`, not the introspection permission.

#### 6.2.34 `sql_probe`

Run ONE read-only SELECT/WITH against a datasource and get rows, the canonical schema, `wall_ms` of query time, and the EXPLAIN plan captured BEFORE the query ran — the bounded free-SQL probe ([Datasources §7D](/docs/datasources.md#7d-the-sql-probe)). A debug probe, not an export.

```json
{
  "name": "sql_probe",
  "description": "Run ONE read-only SELECT or WITH statement against a datasource and return up to `limit` wire-encoded rows, the canonical column schema, wall_ms of query time, and the EXPLAIN plan captured BEFORE the query ran — a bounded debug probe, not an export. The statement is classified before any connection opens: anything but a single SELECT/WITH, or a denylisted verb (INSERT, DROP, ATTACH, EXPLAIN, INTO, ...) anywhere in it, is refused without touching the datasource. Parameters bind as named :name placeholders through the same binder pipeline SQL uses; every referenced name must be supplied in `parameters` with its canonical type. `tempdb` is a scratch check, not an execution: the statement is prepared against an EMPTY scratch H2 in the staging mode (no staged tables, no data). Read `validation_status`: `executed` (`parsed: true`, rows) means a self-contained statement ran; `incomplete` (`parsed: null`, `missing_table`) means H2 stopped at the first staged table it could not find and NOTHING after that point — syntax or names — was checked; an error is a real H2 error (a syntax slip, a `VALUES` column named column1 where H2 says C1) found in milliseconds instead of a full run. To finish an incomplete check, restate the suspect construct over typed, aliased `VALUES` inputs so it executes here, or run the node with its real staged inputs. On a timeout the error details carry wall_ms and the plan, so the plan that explains the timeout survives it. A statement the database refuses for lack of privilege on an existing table is datasource.table_forbidden. The sql text never reaches the audit log — only its SHA-256 and length are recorded.",
  "inputSchema": {
    "type": "object",
    "required": ["name", "sql"],
    "additionalProperties": false,
    "properties": {
      "name": {"type": "string", "description": "Datasource name, or the reserved name tempdb to prepare a staging (H2) statement against an empty scratch engine. validation_status executed means it ran; incomplete with missing_table means H2 stopped at a staged table that exists only inside a full execution, and the rest of the statement is unverified."},
      "sql": {"type": "string", "description": "ONE SELECT or WITH statement. A second statement or a denylisted verb is refused before any connection opens."},
      "parameters": {
        "type": "object",
        "description": "Bind values for the statement's :name placeholders, keyed by name. type is the canonical logical type; value is its wire string (BIGINTEGER/BIGDECIMAL as plain decimal text, temporal in exact ISO forms — TIMESTAMP with an explicit offset or Z —, BINARY as padded standard base64). The forms are judged strictly, exactly as a pipeline parameter is: nothing is trimmed (a padded value is refused), booleans are exactly true/false, and numeric text over 1024 digits is refused before parsing. A null value binds SQL NULL.",
        "additionalProperties": {
          "type": "object",
          "required": ["type"],
          "properties": {
            "type": {"type": "string", "enum": ["BOOLEAN","INTEGER","BIGINTEGER","DECIMAL","BIGDECIMAL","STRING","BINARY","DATE","TIME","TIMESTAMP"]},
            "value": {"type": ["string", "null"]}
          }
        }
      },
      "limit": {"type": "integer", "default": 50, "minimum": 1, "maximum": 500},
      "timeout_seconds": {"type": "integer", "default": 10, "minimum": 1}
    }
  }
}
```

Returns: `{columns: [{name, type, precision?, scale?, nullable?}], warnings, rows, row_count_returned, truncated, wall_ms, timeout_seconds, plan?}` — values wire-encoded per the result-cursor rules, exactly like `datasources_preview_rows` (§6.2.19). `plan` carries `{scan?, estimated_rows?, raw, partitions_scanned?, partitions_total?}`; on LAKE the partitions pair is DuckDB's `Scanning Files: x/y` marker, null when no static file filter pruned (never `y/y` — a filter selecting every file is optimized away entirely). `limit` defaults to 50 and clamps to 500; `timeout_seconds` defaults to 10 and is clamped to the instance's node query timeout for the datasource's dialect ([Configuration §3.2](/docs/configuration.md#32-executor) `node-query-timeout-seconds-by-dialect.<dialect>` when set, LAKE shipping 180; else `node-query-timeout-seconds`, default 60 s; #167) — a probe may run as long as a node on that dialect, never longer than the dialect's default node budget, and a datasource's own `query_timeout_seconds` tightens that bound, never loosens it (#405 — a datasource set to 20 s probes for at most 20 s, the budget its nodes get unless a node or its pipeline sets its own; [Datasources §7D](/docs/datasources.md#7d-the-sql-probe)) — and the payload's `timeout_seconds` is the timeout the statement actually ran under. The schema carries no `maximum` for it because the ceiling is operator configuration, not a constant.

Refusals split into two families. **Argument faults** (JSON-RPC `-32602`, nothing leased, nothing ran): the statement is not a single SELECT/WITH or carries a denylisted verb (the classifier is a conservative token scan — false positives err toward refusal, and a side-effecting function call it cannot see through is NOT caught; the datasource's own DB-user privileges remain the last line), or a `parameters` entry is missing/mistyped — since #265 including a value that is not its declared type's exact wire form: nothing is trimmed, booleans are exactly `true`/`false`, numeric text over 1024 digits is refused before parsing, and the static refusal names the parameter, never the value text (datasources §7D; the shared coercion every parameter surface judges by). **Run failures** (the catalogued `pipeline.node.query_execution_failed` `isError` envelope — a timeout under `pipeline.node.query_timeout`, Pipeline Contract §13.4): a timeout, whose `details` carry `reason: "timeout"`, `wall_ms`, `timeout_seconds` (the timeout it ran under, #167) and the pre-captured plan — the plan that explains the timeout survives it — or a driver refusal, carrying the bounded driver message. A driver refusal whose SQLSTATE is permission-denied (123 §A — and since 186, H2's `90040` "Admin rights are required", the shape a de-privileged in-process session answers with) is instead **`403 datasource.table_forbidden`** — a referenced table exists but this datasource's credentials cannot read it. `tempdb` uses the scratch check below; the real staged tables exist only inside a full execution. The `sql` argument is audited as `sql_sha256` + `sql_length`, never verbatim (§14).

**The `tempdb` scratch check (2026-09-11; contract corrected 2026-09-16, #119; scratch de-privileged 2026-09-21, #186).** `name: "tempdb"` no longer refuses. The statement is PREPARED against a FRESH, EMPTY in-memory H2 opened in the staging engine's own `MODE` and lower-folding (the URL shape `StagingFactory` builds, minus the execution) — no registry, no lease, no visibility gate, nothing to read. The scratch session is a grantless NON-admin user (staging §9.5's two-phase open, shared as `H2RestrictedSession`): the classifier admits only SELECT/WITH, but a SELECT can still call host-reaching functions, and those (`FILE_READ`, `CSVREAD`, …) are refused by the engine with SQLState `90040` — mapped like any permission-denied refusal to `datasource.table_forbidden`, never a 500, and the file's content is never in the answer. The payload always carries `check: "syntax_and_names"` (the check attempted), `engine`, and **`validation_status`**, which says how far the engine got. **`"incomplete"`** — H2 reported a missing table (`42102`/`42103`/`42104`): `{"check", "engine", "validation_status": "incomplete", "parsed": null, "missing_table": "<the first staged table H2 could not find>", "wall_ms", "note"}`. H2 stops preparing at the first table it cannot resolve, so nothing after it has been checked — measured on the pinned 2.3.232: `SELECT a.id FROM absent_a a FULL OUTER JOIN absent_b b ON a.id = b.id` reports `absent_a` on the empty scratch and is a `42000` syntax error at `OUTER` once both tables exist (`SqlProbeH2Test`); an acceptance run had matched the SQL hash of a "passing" probe to its subsequently failing execution. `parsed` is deliberately `null`, not omitted: the key keeps its place and the value withdraws the affirmative the pre-#119 payload made (`parsed: true`, "the SQL is sound"). The note names the two ways to finish the check — restate the suspect construct over typed, aliased `VALUES` inputs so it executes on the scratch (validates the construct, not the real column types or data), or use `pipelines_execute` to run the DAG with its real staged inputs, which validates against the real schema at the cost of a run (`pipelines_execute_node` cannot read tempdb or reuse staged tables from an earlier execution); `templates_render` checks what the template emits, not whether it runs. **`"executed"`** — the statement was self-contained (a `VALUES` spine, a constant): `parsed: true` plus the ordinary probe payload above and a note that this validates the statement as written, not other parameter values or the real staged inputs. **Anything else** → the catalogued `pipeline.node.query_execution_failed` with H2's message — never disguised as incomplete. The case that motivated the check: an agent's caller node died on `Column "column1" not found` (H2 names `VALUES` columns `C1…`) after five source nodes ran green — a full DAG run to learn one H2 spelling. Parameters bind exactly as in a real probe; the classifier still admits only one SELECT/WITH; the scratch never creates a missing table from a guessed shape and never reads another execution's tempdb. Compatibility: no tool name, argument or count changed; `parsed` became nullable on the incomplete branch and `validation_status` was added — no typed consumer of the payload exists in this repository (the field is read by agents, not code), and this paragraph is the contract.

**Permission:** `datasource.sql_probe` — this returns arbitrary customer ROW DATA; the same 037 F reasoning as `datasources_preview_rows`.

#### 6.2.35 `executions_cancel`

Request cancellation of a RUNNING execution — the MCP twin of `DELETE /api/v1/executions/{id}` ([REST API §10.4](/docs/rest-api.md#104-cancel-execution)) with one rule REST does not have: the **same-credential rule**.

```json
{
  "name": "executions_cancel",
  "description": "Request cancellation of a RUNNING execution. This key can cancel ONLY an execution its own MCP calls started: the execution must have been triggered via MCP by this key's user, and an audit row must pair this key with the execution's correlation id — an execution started over REST, the UI, a pipeline node, a published endpoint, or another key of the same user is refused, and the refusal names which rule fired. A non-RUNNING execution is refused with its current status. Cancellation is requested, not awaited: the flag reaches the executing instance within about one poll interval (immediately when same-instance); poll executions_get for the terminal ABORTED status. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": ["execution_id"],
    "additionalProperties": false,
    "properties": {
      "execution_id": {"type": "string", "format": "uuid"}
    }
  }
}
```

Returns: `{execution_id, status: "cancellation_requested"}`. Cancellation is REQUESTED, not awaited — the flag reaches the executing instance within about one poll interval (immediately when same-instance); poll `executions_get` (§6.2.14) for the terminal `ABORTED`.

The same-credential rule, in order: an execution the caller may not see is the same not-found every read path answers (ownership before anything else); a non-RUNNING execution is `pipeline.execution.not_running` with its current status in `details`; an execution started outside MCP (REST, the UI, a PIPELINE node, a published endpoint) is `auth.role_required` with `details.reason: "started_outside_mcp"`; and one started by a DIFFERENT credential — another key of the same user included — is the same code with `details.reason: "different_credential"`. The join that proves "this key started it" is an audit row pairing this key id with the execution's correlation id, because `pipeline_executions` records the owner USER, never the key. `pipelines_execute` writes that row (`mcp.execution.launched`) BEFORE the blocking run begins — the dispatcher's end-of-call `mcp.tool.called` row cannot exist while the call is still blocking, and in-flight is exactly when a cancel matters.

**Permission:** `execution.cancel` — the REST twin's permission (the same-credential rule is a handler gate, not expressible as a permission; its refusals are `auth.role_required` with `details.reason`).

**Mutating.** Declared `mutating` in the tool catalog — cancellation IS a write (it ends a running execution), and the `mcp.tool.write` row is the trace of WHOSE key stopped it (§14).

#### 6.2.36 `templates_update`

Update an existing template by writing its DRAFT (versioning §3.2/§5.1/§5.2) — the MCP twin of REST `PUT /templates` (§8.4), and the template mirror of [`pipelines_update`](#625-pipelines_update). Before 117 the only MCP path to change a template draft was `templates_purge_draft` + `templates_create`, and purge is refused the moment any pipeline version pins the template — exactly the state an authoring agent is in mid-build.

```json
{
  "name": "templates_update",
  "description": "Update an existing template by writing its DRAFT — the first update after a release creates the draft (copy-on-write); later updates overwrite that same draft in place. Requires expected_hash: the body_hash you read (templates_get, or a previous templates_create/templates_update result) for the version you based your edit on. The result carries status='DRAFT' — your work is NOT released; a human releases it from the UI. On template.version.conflict someone modified it after you loaded it: re-read with templates_get, rebase, retry; never retry blindly. The body takes the same fields as templates_create, and the template's type is fixed at creation — an update naming a different type is refused with template.validation.type_immutable. type and dialect are optional: omitted, the working version's are inherited; a different dialect is refused. templates_purge_draft is for a template that was a mistake, not for editing one.",
  "inputSchema": {
    "type": "object",
    "required": ["id", "expected_hash", "display_name", "description", "body"],
    "properties": {
      "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_.-]{0,63}(/[a-z0-9][a-z0-9_.-]{0,63}){1,9}$", "description": "Template to update — the FOLDER PATH id it was created under (acme/finance/daily_orders.sql). Required here: §9.6, the name never travels in a path or anywhere else. There is no rename, so the id cannot change — an unknown id is the catalogued template.not_found."},
      "expected_hash": {"type": "string", "description": "The body_hash of the version this edit is based on — templates_get, or a previous templates_create/templates_update result. A mismatch is a 409 template.version.conflict; re-read and rebase, never retry blindly."},
      "engine": {"type": "string", "enum": ["freemarker", "none"], "default": "freemarker", "description": "Template engine, matched to the type: 'freemarker' for sql/html, 'none' for the script types ('jsonata'/'javascript' — the body is evaluated, never rendered). Any other pairing is refused with template.validation.engine_unsupported."},
      "type": {"type": "string", "enum": ["sql", "html", "jsonata", "javascript"], "description": "Template kind — fixed at creation, so on an update it is OPTIONAL: omitted, the working version's is inherited; stated, it must equal it (template.validation.type_immutable otherwise)."},
      "dialect": {"type": "string", "enum": ["POSTGRES", "ORACLE", "MSSQL", "MYSQL", "H2", "DUCKDB", "SQLITE", "LAKE"], "description": "Optional on update: omit it and the working version's dialect is inherited. When present it must be the dialect the template already has — a different one is refused with template.validation.dialect_invalid (a template pinned by pipeline nodes cannot change engine; create a new template instead). Never present for an html template."},
      "display_name": {"type": "string"},
      "description": {"type": "string", "description": "Free text. State the variables the body expects and their types — the template declares none."},
      "imports": {
        "type": "array",
        "description": "Library templates whose macros this body calls. Aliases must be unique within the template; each referenced template must exist at that exact version and be is_library=true.",
        "items": {
          "type": "object",
          "required": ["id", "version", "alias"],
          "properties": {
            "id": {"type": "string"},
            "version": {"type": "integer"},
            "alias": {"type": "string", "description": "Namespace the macros are bound to, e.g. 'dates' → <@dates.date_range .../>."}
          },
          "additionalProperties": false
        }
      },
      "is_library": {"type": "boolean", "default": false, "description": "true if this template exists to be imported by others. A library body contains only <#macro>/<#function> definitions — no output outside macro definitions. body is still required."},
      "body": {"type": "string", "description": "Template source. Must not contain <#import> or <#include>."},
      "contract": {"type": "object", "description": "Script contract (script types only — refused on sql/html with template.blocks_not_allowed): { mode: 'row'|'table'|'value', inputs: { name: { kind: 'table', columns: [{name, type, precision?, scale?, nullable?}] } or { kind: 'value', type, precision?, scale? } }, output: { kind: 'table'|'value'|'object', ... }, rejects?: boolean }. Types are LogicalType wire names; a row-mode contract requires exactly one table input."},
      "invariants": {"type": "array", "description": "Script invariants: [{ name, expr, message }] — JSONata over { rows, rejects, inputs }, must be true on every test case and every real execution. May be empty but is required on a script type."},
      "tests": {"type": "array", "description": "Script test cases: [{ name, input: { rows?, inputs?, meta?, now? }, expect: { output } or { refusal } }] — non-empty, at least one case whose every table input and rows are empty, expect is exactly one of output/refusal. Save runs the suite; release re-runs it."},
      "implements": {"type": "array", "items": {"type": "string", "format": "uuid"}, "description": "Script types only: the learned facts this version implements (WORKSPACE definition, exclusion or preference ids of this workspace). Omitted, the citations of the version you edit are kept (a new draft inherits the released version's); [] clears them. Not content — outside body_hash — so an update whose body equals the released version and changes only implements writes them ON the released version and opens no draft: that is how you clear needs_review (cite the superseding fact). An id this workspace cannot cite is template.implements_unresolved. At most 50."}
    },
    "additionalProperties": false
  }
}
```

The save-time validation is the SAME parse-only set `templates_create` runs ([Templates §7.1](/docs/templates.md#71-save-time-validation-is-parse-only)); the write is the SAME `TemplateDraftService.write` call REST §8.4 makes — one write path, two surfaces. First write after a release copies the released version to a draft (copy-on-write); later writes overwrite that one draft in place.

Returns: the stored version's projection — `id`, `version`, `status`, `body_hash` (carry this into the next write), `dialect`, `type`, `body`, and the `draft` pointer (`version`, `body_hash`, `updated_by`, `updated_at`) when the write produced a draft. **The update does NOT release** (versioning D4): a human releases from the UI, and `templates_render` (§6.2.9) is the preview step before you hand the draft over. A write whose CONTENT equals the released content is the §5.1 no-op: `status: "RELEASED"`, no draft opened, no version number burned. An unknown id is `template.not_found`; a stale `expected_hash` is `template.version.conflict` with the current state in `details`; an update naming a different `type` than the template's established one is `template.validation.type_immutable` (046 §5.3). **`type` is optional on update too (136 §D, T289b):** omitted, the working version's type is inherited — it used to default to `sql`, so an html template updated without `type` was refused `type_immutable` for a field it never changed; stated, it must equal the established one (`template.validation.type_immutable`, the draft service's own refusal). **`dialect` is optional on update (135 §C, T276):** omitted, the working version's dialect is inherited before validation — the draft already carries it, and an agent resending the value it read is the round trip this saves; present, it must equal the established one, and a different dialect is `template.validation.dialect_invalid` with `details.dialect` / `details.established_dialect` / `details.template_id`, refused before validation or any write (a template's pipeline nodes pin it against a source of that dialect; another engine is a new template). The REST `PUT /templates` (§8.4) is unchanged — it still requires the field and does not compare it; the inheritance is the MCP surface's, resolved before the shared write. There is deliberately no `confirm_new_root` argument: the update names a template that already exists and cannot mint a folder.

**`implements` on update (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)).** The citations ride the same write and are NOT content — outside `body_hash`, so they never decide whether a draft opens. Stated, they replace the written version's citations (`[]` clears); **omitted, they are inherited** from the version the edit is based on (owner ruling 2026-09-25): an in-place draft write keeps the draft's, a write that opens a new draft copies the released version's, a §5.1 no-op keeps its own. A write whose content equals the released version and that states `implements` lands the citations **on the RELEASED version** — no draft, no version burned — which is how a `needs_review` version is cleared (cite the superseding fact). While a draft is open the existing precondition makes every write the draft's, so a released version's citations are edited when no draft is open. Refusals as on create: `template.implements_unresolved`, and `template.blocks_not_allowed` on `sql`/`html`.

**Permission:** `template.update`. **Mutating.**

#### 6.2.37 `semantics_record`

Record ONE fact you learned about a datasource that introspection could not tell you ([learned-semantic-layer design](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md) §7.1) — the second half of the skill's *learn before you assume* step: what a session learned should not die with it.

```json
{
  "name": "semantics_record",
  "description": "Record ONE fact you learned about a datasource that introspection could not tell you — a unit, a time zone, a sample rate, a grain, what a coded value means, a join that holds, a trap — so the next session reads it beside the columns instead of probing again. Never record what introspection already returns (types, keys, comments). refs name the table(s) and column(s) the fact is about, structurally; every ref is checked against the live schema and an unknown one is refused. The fact's text must agree with its refs: a catalog table the text names but refs omit is ADDED to the stored refs for you (refs_added in the result names what was added); a near-miss of a listed table is refused as semantics.ref_mismatch (the refusal names the nearest listed table). A lake table's ref takes schema as ONE dotted namespace string ({\"schema\": \"lake.mart\", \"table\": \"events\"}), never a namespace array. Pass evidence_sql (the SELECT that showed the fact): it runs once, its first rows become evidence_summary, and the fact is stored as observed — without it the fact is only asserted. scope DATASOURCE is about the data over the table's WHOLE window and is shared with every workspace the datasource is granted to — an observation tied to one window belongs in the pipeline description, not in a fact; scope WORKSPACE (definition, exclusion, preference) is this organisation's meaning and stays here — and a WORKSPACE rule may carry NO refs: a rule that spans datasources ('busiest day = A + B combined') is recorded once, against the datasource the question is mostly about, and names no table; a DATASOURCE fact always names at least one (a unit without a column is meaningless — semantics.fact_invalid). To correct a stale or wrong fact, record the replacement with supersedes: the old one is retired as superseded. An identical live fact is refused as semantics.duplicate. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": [
      "scope",
      "datasource",
      "kind",
      "fact"
    ],
    "additionalProperties": false,
    "properties": {
      "scope": {
        "type": "string",
        "enum": [
          "DATASOURCE",
          "WORKSPACE"
        ],
        "description": "DATASOURCE: a fact about the data, visible wherever the datasource is granted. WORKSPACE: this organisation's meaning, visible here only."
      },
      "datasource": {
        "type": "string",
        "description": "Datasource name (must be granted to this workspace)."
      },
      "kind": {
        "type": "string",
        "enum": [
          "unit",
          "time_zone",
          "sampling",
          "grain",
          "window",
          "enum_meaning",
          "join",
          "caveat",
          "format",
          "definition",
          "exclusion",
          "preference"
        ],
        "description": "What the fact is about. unit, time_zone, sampling, grain, window, enum_meaning, join, caveat, format are DATASOURCE kinds; definition, exclusion, preference are WORKSPACE kinds."
      },
      "fact": {
        "type": "string",
        "minLength": 8,
        "maxLength": 1000,
        "description": "The fact, in one or two sentences, specific enough to act on: 'value is already in the unit named by unit_col', 'pickup_ts is naive local time (America/New_York)'."
      },
      "refs": {
        "type": "array",
        "items": {
          "type": "object",
          "required": [
            "table"
          ],
          "additionalProperties": false,
          "properties": {
            "schema": {
              "type": "string",
              "description": "Namespace as datasources_get_tables reported it (a label, or the dotted catalog.schema form). Omit for the connection's current schema."
            },
            "table": {
              "type": "string",
              "description": "Table name exactly as datasources_get_tables returned it."
            },
            "column": {
              "type": "string",
              "description": "Column name exactly as datasources_get_columns returned it. Omit for a table-grain fact (grain, window, sampling, a table-level caveat)."
            }
          }
        },
        "description": "The object(s) the fact is about. One ref for a column fact, two for a join, a column-less ref for a table-grain fact. Required (at least one) for a DATASOURCE kind; a WORKSPACE rule that spans datasources omits it."
      },
      "evidence_sql": {
        "type": "string",
        "description": "ONE read-only SELECT/WITH that shows the fact (no :parameters). Runs once at record time under the sql_probe rules; a statement that fails refuses the record."
      },
      "evidence_summary": {
        "type": "string",
        "maxLength": 300,
        "description": "What the evidence showed, in your words. Defaults to the probe's first rows."
      },
      "source_pipeline_id": {
        "type": "string",
        "format": "uuid",
        "description": "The pipeline you learned this while building, if any. Shown only to readers who can read that pipeline."
      },
      "source_version": {
        "type": "integer",
        "minimum": 1
      },
      "supersedes": {
        "type": "string",
        "format": "uuid",
        "description": "The id of the fact this one replaces (a stale or wrong one); it is retired with reason superseded."
      }
    }
  }
}
```

Returns: the stored fact in the FULL shape — the §6.2.18a block plus `datasource`, `refs[]` (`{schema, table, column}`, normalised), `evidence_sql`, `recorded_by`, `source_version`, `supersedes`, `retired_at`/`retired_reason` (omitted-when-absent) — plus `refs_added` when the tool appended a ref for you (a catalog table the text named exactly and `refs` omitted; the stored `refs[]` already carries it, each entry the same `{schema, table, column}` shape). Validation, in order, each a catalogued refusal before anything is written ([Pipeline Contract §13.15](/docs/pipeline-contract.md#1315-learned-semantics)): `kind` in the closed list AND of the requested scope (`semantics.kind_invalid`); the fact window, `refs ≥ 1`, the summary cap (`semantics.fact_invalid`); every ref resolves against the LIVE schema — one column read per referenced table, which is also the table's fingerprint (`semantics.ref_unresolved`; the store never starts stale); no identical live fact (`semantics.duplicate`, `details.existing_id`); `evidence_sql` runs ONCE through the `sql_probe` path — a statement the classifier refuses or that names a `:parameter` is `semantics.evidence_refused`, one the database refuses or that times out is `semantics.evidence_failed`, and the fact is NOT recorded. Trust follows the evidence: `observed` with it, `asserted` without. `supersedes` retires the named fact with reason `superseded` in the same call; an id the workspace cannot see — or a `source_pipeline_id` it cannot read — is the not-found answer (D-R5). A DATASOURCE-scope record on a datasource not granted to this workspace is the §5.3 not-found BEFORE anything runs — that gate IS the grant requirement (D-S8). Audited as `semantics.recorded` (kind, scope, datasource, refs, trust, via — never the fact text or the SQL) beside the dispatcher's `mcp.tool.write`.

**`refs` is optional for a WORKSPACE rule (136 §B / T278).** A `definition`, `exclusion` or `preference` that spans datasources ("busiest day = A + B combined") is recorded ONCE, against the datasource the question is mostly about, with `refs` omitted (or `[]`): it stays bound to that datasource — for visibility, and for the `definitions` block §6.2.10/§6.2.11 carry — but names no table, so nothing resolves it and nothing marks it stale. The text/refs check still runs against that datasource's catalog (an exact table name in the text is added as a ref, a near-miss is `semantics.ref_mismatch`); a table of ANOTHER datasource in the text is prose. A DATASOURCE kind keeps requiring at least one ref — a `unit` without a column is meaningless — refused as `semantics.fact_invalid` (`details.field: "refs"`).

**Permission:** `semantic.record` — recording is an authoring act (D-S8), the same bar as writing a pipeline that reads the datasource. **Mutating.**

#### 6.2.38 `semantics_list`

The facts on a datasource this workspace can see, with trust, drift, refs, evidence and provenance.

```json
{
  "name": "semantics_list",
  "description": "List the learned facts recorded on a datasource this workspace can see — every DATASOURCE fact (whoever recorded it) and this workspace's own WORKSPACE facts — with trust, drift, refs, the evidence SQL and who recorded it through what. The same facts also arrive inline on datasources_get / _get_tables / _get_columns, which is where to read them while authoring; use this to review, to find a fact's id to supersede or retire, or to answer 'what was recorded since <time>'. Every WORKSPACE fact carries implemented_by: the template versions that cite it as the script implementing that rule — reuse one before writing your own. Retired facts are hidden unless include_retired.",
  "inputSchema": {
    "type": "object",
    "required": [
      "datasource"
    ],
    "additionalProperties": false,
    "properties": {
      "datasource": {
        "type": "string",
        "description": "Datasource name."
      },
      "table": {
        "type": "string",
        "description": "Only facts with a ref on this table."
      },
      "scope": {
        "type": "string",
        "enum": [
          "DATASOURCE",
          "WORKSPACE"
        ]
      },
      "include_retired": {
        "type": "boolean",
        "default": false
      },
      "since": {
        "type": "string",
        "format": "date-time",
        "description": "Only facts recorded at or after this ISO-8601 instant."
      }
    }
  }
}
```

Returns: `{datasource, facts: [FULL fact shape, oldest first], count}`. Visibility is the store's one predicate: every DATASOURCE fact on the datasource (whoever recorded it, `from_this_workspace` says which) and this workspace's own WORKSPACE facts. `since` answers the §9 acceptance question "what was recorded since <time>". Served AS STORED — this read opens no connection, so no drift is recomputed; `drift` carries the message a previous introspection read left. `retired` facts appear only with `include_retired: true`. **Every WORKSPACE fact carries `implemented_by`** (7e, [Templates §3.4](/docs/templates.md#34-implements-and-drift)): `[{template_id, version}]` — the live (DRAFT or RELEASED) template versions of this workspace citing it as the script implementing it, `[]` when none, read in one index query for the whole listing and filtered by the key's `template.read` lens (a promoter's key lists only RELEASED versions of the templates it may read; a DISCARDED version is never listed). A DATASOURCE fact never carries the key: nothing can cite it. The learn-first step is: find the definition, find the script that implements it, reuse before writing.

**Permission:** `semantic.read` — facts ABOUT the data, never row data (the `templates_used_by` reasoning).

#### 6.2.39 `semantics_retire`

Retire one fact with a reason — the D-S11 verb: a state, never a delete.

```json
{
  "name": "semantics_retire",
  "description": "Retire one learned fact with a reason — it stops being served beside the columns but keeps its row (facts are never deleted; history is the audit). Prefer semantics_record with supersedes when you know the correct fact: that retires the old one and records the new in one step. A fact this workspace cannot see is not-found; a DATASOURCE fact another workspace established needs datasource.manage — the permission decides, not a role. Mutating.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "reason"
    ],
    "additionalProperties": false,
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The fact's id, from semantics_list or an introspection response."
      },
      "reason": {
        "type": "string",
        "minLength": 3,
        "maxLength": 300,
        "description": "Why — one sentence, kept on the row and in the audit log."
      }
    }
  }
}
```

Returns: the retired fact in the full shape (`trust: "retired"`, `retired_at`, `retired_reason`). A fact this workspace cannot see is `semantics.not_found` (D-R5, identical for an id that exists nowhere); a DATASOURCE fact recorded from ANOTHER workspace needs `ws_admin` — `auth.role_required` otherwise (you do not silently retire what someone else established). Audited as `semantics.retired`. Prefer `semantics_record` with `supersedes` when the correct fact is known: one call retires the old and records the new.

**Permission:** `semantic.retire`. **Mutating.**

#### 6.2.40 `docs_list`

The served manual's catalog, as a tool (120, ruling R3; the by-area shape is the 242a record §4). Without `area`: the operating core first, then one entry per functional area — the area guide, or the area's tools reference where no guide exists yet — with each area's other documents nested under `references`, so the top-level list stays a short routing table and nothing is invisible. With `area`: that area's documents, flat. The same rendered set the resources (§7.2.4) and the HTTP twin serve, from the same `DocSet`.

```json
{
  "name": "docs_list",
  "description": "The served manual's catalog: the operating core (core) first, then every area's entry point — the area guide, or its tools reference where no guide exists yet — with each area's other documents nested under references (name, title, purpose, size). Optional area filter (pipelines, executions, templates, scripts, datasources, lake, endpoints, core) returns that area's documents flat. Documents flagged over_budget are served by section only. Read-only.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "area": {"type": "string", "enum": ["core", "pipelines", "executions", "templates", "scripts", "datasources", "lake", "endpoints"], "description": "Return only this area's documents, flat."}
    },
    "additionalProperties": false
  }
}
```

**Permission:** `docs.read` — the §6.2.23 reasoning: the manual this deployment serves is a property of the BUILD and the boot configuration, identical for every caller, every key and every workspace.

**Response:** `[{name, area, layer, title, purpose, chars, over_budget, sections: [{id, title, chars}], references?: [{name, layer, title, purpose, chars, over_budget}]}]` — the core first, then the areas in catalog order (core, pipelines, executions, templates, scripts, datasources, lake, endpoints); `sections` cuts the document at `##` headings, slugged by the GitHub rules the docs audit spells; a document over the 24,000-character response budget is flagged `over_budget` and served by section only (§6.2.41). `references` appears on area entry points, listing the rest of the area's documents.

#### 6.2.41 `docs_get`

One document of the served manual — the same bytes the `datapipelines://docs/skill/<name>` resource serves; the by-section shape is the 242a record §4.

```json
{
  "name": "docs_get",
  "description": "One document of the served manual: `name` is `core` for the operating core, an area guide name (pipelines-authoring, templates, scripts, lake, endpoints) or a reference name from docs_list. Optional `section`: a section id from docs_list, for the by-section read large documents require (they answer whole-document calls with their section list); a response carrying `next` continues at that id. Unknown names list the catalogued ones. Read-only.",
  "inputSchema": {
    "type": "object",
    "required": ["name"],
    "properties": {
      "name": {"type": "string", "description": "The document name: `core` for the operating core, or a name from docs_list, e.g. pipelines-authoring."},
      "section": {"type": "string", "description": "A section id from docs_list (or a `next` continuation id) for the by-section read."}
    },
    "additionalProperties": false
  }
}
```

**Permission:** `docs.read`.

**Returns:** whole document `{name, title, area, markdown}` (within the 24,000-character budget), or by section `{name, title, section, markdown, next?}` — `next` names the continuation id when the section itself was split at its inner headings. The `.md`-suffixed form is accepted like the resource's own tolerance. The one-release aliases (`skill`, `authoring-playbook`, `connecting`, `dp-lake`, `error-codes`, `naming`, `node-types`, `pipeline-schema`, `tools`) resolve to their successors and the response carries the NEW name (record §4, ruling O4; removed the release after).

**Errors:** an unknown name is `mcp.doc_not_found` (pipeline-contract §13.16) with `details.known_docs`; a whole-document call on an over-budget document is the same code with `details.reason: "document_over_budget"` and `details.sections` — served by section only, so the refusal lists the section ids; an unknown section id carries `details.reason: "unknown_section"` and `details.known_sections`.

#### 6.2.42 `pipelines_run_checks`

Run a pipeline version's release checks (`checks[]`, pipeline-contract §3.3) NOW (140). This is the only way an agent obtains an `observed` value: the SERVER runs every check through the same `PipelineCheckRunner` REST `POST /pipelines/{id}/versions/{version}/checks/run` (rest-api §5.16), the UI and the release gate ride, and persists one `pipeline_check_runs` row per check before returning. There is deliberately **no `pipelines_record_check` tool** — no tool records an observed value from a caller, because an observed value the caller supplied would be a claim, not a run.

```json
{
  "name": "pipelines_run_checks",
  "description": "Run a pipeline version's release checks (checks[]) NOW, against their own datasources. The SERVER runs every check and persists one pipeline_check_runs row per check before returning — only the server's own run produces `observed`, and there is deliberately no pipelines_record_check tool: no tool records an observed value from a caller. Each run's verdict is pass (observed satisfied expected), fail (a value was produced and did not satisfy it), or error (no verdict could be formed: the datasource was unresolvable or unreachable, the statement was refused or returned a shape the expectation cannot compare, or the parameters did not bind — the truth recorded, never silently a fail). With no version the WORKING version's checks run (the draft when one exists, else the latest released). Returns {version, runs: [{check_id, name, expected, observed, verdict, message, ran_at}]}; an empty checks[] returns an empty runs array.",
  "inputSchema": {
    "type": "object",
    "required": ["id"],
    "properties": {
      "id": {"type": "string", "format": "uuid"},
      "version": {"type": "integer", "description": "Specific version whose checks to run. Defaults to the WORKING version: the draft when one exists, else the latest released. Never clamped — an unknown version is refused, not rounded to the latest."},
      "parameters": {
        "type": "object",
        "description": "Object whose keys match the pipeline's declared parameters — the same binding pipelines_execute uses: undeclared keys are ignored, defaults fill the execute way, and the calculator context is NOT available to a check. Values must match the declared types (BIGINTEGER and BIGDECIMAL as strings, others as JSON native types).",
        "additionalProperties": true
      }
    }
  }
}
```

**Permission:** `pipeline.run_checks` — the REST twin's permission (auth.md §7.6): a viewer runs what they can read, and a check run returns no row data beyond the one observed cell per check. The catalog declares the tool **mutating** (it writes the run rows), so the dispatcher's `mcp.tool.write` row is the trace of who commissioned them.

**Returns:** `{version, runs: [{check_id, name, expected, observed, verdict, message, ran_at}]}` — the REST `checkRuns` shape exactly: `expected` serialized from the `CheckExpectation` model (its non-null members only), `verdict` one of `pass` | `fail` | `error`, and `observed`/`message`/`ran_at` null exactly when the run has none. An `error` verdict means no verdict could be formed — the datasource was unresolvable or unreachable, the statement was refused, it returned a shape the expectation cannot compare (two columns for a `value` check), or the parameters did not bind; the reason is in `message`. It is the truth recorded, never silently a `fail`. A version with no `checks[]` returns an empty `runs` array.

**Errors:** an unknown pipeline or version is the §6.2.3 not-found refusal; `{version: 0}` is `-32602`, never clamped.

#### 6.2.43 `templates_evaluate`

Evaluate a script template over a caller-supplied input object (7b, transform-nodes design §9.1) — `sql_probe`'s twin for the script types. The version resolves exactly as `templates_render`'s (explicit `version`, else the working version — the draft when one exists, else the latest released); the §5.1 input check runs first, the body evaluates on the bounded evaluation pool under `datapipelines.script.evaluate-timeout-seconds`, the output passes the type gate against the contract, and every invariant runs on it. Nothing is staged and nothing is stored.

```json
{
  "name": "templates_evaluate",
  "description": "Evaluate a script template ('jsonata'/'javascript') over a caller-supplied input object and return { output, rejects, invariants, console } — no staging, no Context. Use this to run a script's body against one input the way its test suite does (sql_probe's twin; templates_render is for sql/html and refuses a script type with template.render_not_applicable). The version resolves as templates_render does: omitted, the working version (the draft when one exists, else the latest released). A refusal is the code with its detail — a type-gate refusal, an input-contract violation, or an engine refusal (timeout, resource limit, pool exhausted).",
  "inputSchema": {
    "type": "object",
    "required": ["id", "input"],
    "properties": {
      "id": {"type": "string"},
      "version": {"type": "integer", "description": "Specific version. Defaults to the working version: the draft when one exists, else the latest released."},
      "input": {"type": "object", "description": "The input object of the template's contract: { rows: [...], inputs: {...} } — in row mode `rows` is the batch and `inputs` holds the value inputs only (the table input is NOT listed); in table/value mode `inputs` holds every input, tables as arrays. Optional `now` (ISO-8601) pins the clock: without it the $now()/$millis() builtins refuse.", "additionalProperties": true},
      "now": {"type": "string", "description": "Optional ISO-8601 instant the $now()/$millis() builtins return for this evaluation. Absent, a body that reads the clock refuses (a script is a pure function of its inputs — the clock is an input)."}
    },
    "additionalProperties": false
  }
}
```

**Permission:** `template.evaluate` — the `templates_render` row (R6: evaluating untrusted code on the server is the same authoring act as rendering). The catalog declares the tool **not mutating**.

**Returns:** `{ output, rejects, invariants: [ { name, passed, message } ], console: [ { level, text } ] }`. `output` is the function's return after the type gate (the rows array, `{ rows, rejects }` when the contract declares rejects, or the one value/object in value mode); `rejects` is the rejected half with its reasons; each invariant verdict is `{ name, passed, message }`. `console` is always present (empty when unused), for example `[{"level":"log","text":"mapped 2 rows"}]`; failures retain captured lines under `details.console`.

**Errors:** an unknown id or version is `template.not_found`; an `sql`/`html` id is `template.contract_invalid` with `details.rule: "type_not_script"`; an input that violates the contract is the record §5.1's input-check code; an evaluation failure is the engine's own typed refusal (evaluation failure, timeout, resource limit, pool exhausted) or the type gate's (shape, value, precision, size) — §13.18 names them (7c). The `mcp.tool.called` audit row carries `tool`, `template`, `version` and `outcome` — never the input object or the output (§9.5).

#### 6.2.44 `parameter_sets_list`

List the key's pinned workspace's parameter sets, or browse ONE level of the name tree (`prefix` — the pipelines listing's shape; names are folder paths, P24). Every row carries the set's `id` (what the other five tools take), `name`, `display_name`, `version`, `status` and `current_version`. With a non-blank `q` (and no `prefix`) it instead SEARCHES name, display name and description case-insensitively — the same lensed read the pages search through — and a `q` is ignored while `prefix` is present (browse and search are different presentations). A promoter's key sees only RELEASED sets newer than the promotion target's (the lens); every other set is absent for it.

```json
{
  "name": "parameter_sets_list",
  "description": "List the parameter sets of the key's pinned workspace, or BROWSE one level of the name tree. Parameter sets are versioned definitions of form controls (selectors, inputs) that a client evaluates server-side; every row carries the id you pass to the other parameter_sets_* tools. Names are FOLDER PATHS (acme/sales/region_filters): pass prefix to browse one level — prefix:\"\" lists the roots, prefix:\"acme\" what is directly under acme — and the response separates folders from sets at that level. Pass q to SEARCH name, display name and description case-insensitively; q is ignored while prefix is present. A promoter's key sees only RELEASED sets newer than the promotion target's (the promoter lens); every other set is absent for it.",
  "inputSchema": {
                  "type": "object",
                  "properties": {
                    "prefix": {"type": "string", "description": "Browse ONE level of the name tree at this prefix. Empty string browses the roots."},
                    "q": {"type": "string", "description": "Case-insensitive substring search over name, display name and description (the whole path counts as the name); ignored when prefix is present — pass prefix to browse, q to search. At most 200 characters after trimming; a longer q is refused as invalid params."},
                    "limit": {"type": "integer", "default": 50, "maximum": 200}
                  }
                }
}
```

**Permission:** `parameter_set.read` — every role (the promoter lensed). **Returns:** `{prefix, folders: [{path, segment, parameter_set_count}], parameter_sets: [{id, name, display_name, version, status, current_version}], returned}`. With `q` (no `prefix`) the search shape is `{q, parameter_sets: [the same rows], returned, total}` — no `folders` (a search is not a level), and `total` is the full match count so a truncated `limit` is visible. A `q` over 200 characters after the trim is refused `-32602 invalid params` before any read (#490, the shared rule since #504b; the message names the limit, the length and `request.query_too_long`, never the term) — the same `SearchNeedle` bound every list tool applies, and REST answers with `400`.

#### 6.2.45 `parameter_sets_get`

Read one set by id: the WORKING version's full §3 document (every parameter's type, kind, cardinality, source, depends_on, constraints, presentation), the lifecycle state (`version`, `status`, `body_hash`, `current_version`, the `draft` pointer). Another workspace's set — or a lens-hidden one — answers `parameter.not_found`, never a permission error (§11A.1).

```json
{
  "name": "parameter_sets_get",
  "description": "Read one parameter set by ID: the WORKING version's full §3 document (every parameter with its type, kind, cardinality, source, depends_on, constraints and presentation), plus the lifecycle state — version and status name the returned row (the DRAFT when one exists, else the current release), current_version names the latest release, and body_hash is what an update's expected_hash carries. A set of another workspace — or one the promoter lens hides — answers not-found, never a permission error. Nothing is evaluated here; parameter_sets_evaluate runs one.",
  "inputSchema": {
                  "type": "object",
                  "required": ["id"],
                  "properties": {
                    "id": {"type": "string", "format": "uuid", "description": "The set's id (parameter_sets_list returns it)."}
                  },
                  "additionalProperties": false
                }
}
```

**Permission:** `parameter_set.read`. **Returns:** `{id, name, display_name, description, version, status, body_hash, current_version, draft?, document}` — `draft` is null under a promoter's narrowing lens (a promoter never sees a draft pointer, 178; `pipelines_get`'s rule). **Errors:** an unknown id is `parameter.not_found`.

#### 6.2.46 `parameter_sets_create`

Create a parameter set; version 1 lands DRAFT (a human releases it; no tool releases anything). The document is validated IN FULL at save (the record's §4) — including the metadata execution of every source template against its datasource — so a set whose selector cannot be proven is refused, not stored. A new top-level folder needs `confirm_new_root: true` (the 094 rule; the refusal names the existing roots, `test/` exempt). Before creating a `MULTI` SELECT with no `presentation.control`, ASK the person how it renders (dropdown, checkboxes, list) and set the control — §13a.2's ask-first rule (the manual's `parameters` area carries it).

```json
{
  "name": "parameter_sets_create",
  "description": "Create a parameter set: version 1 lands as a DRAFT (a human releases it; no tool releases anything). The document is validated in FULL at save — including a metadata run of every source template against its datasource — so a set whose selector cannot be proven is refused, not stored. A NEW top-level folder is refused until you confirm it: reuse an existing root, or ask the person first and then pass confirm_new_root: true. Before creating a SELECT with cardinality MULTI and no presentation.control, ASK the person how it should render (dropdown, checkboxes or list) and set presentation.control — a MULTI without a hint is refused by this rule's ask-first discipline and renders as a dropdown by default everywhere else. Returns the created set with its id, which the other tools take.",
  "inputSchema": {
                  "type": "object",
                  "required": ["name", "display_name", "parameters"],
                  "properties": {
                    "name": {"type": "string", "description": "Folder-path name (2-10 segments, lower-case): acme/sales/region_filters."},
                    "display_name": {"type": "string", "description": "Human label, 1-120 characters."},
                    "description": {"type": "string", "description": "Optional; 2000 characters max."},
                    "parameters": {
                      "type": "array",
                      "description": "The ordered parameter definitions (the record's §3.2): name, label, type, kind (INPUT|SELECT), cardinality (SINGLE|MULTI), required, default_value, source (constants | template+datasource), depends_on, hidden_expression, disabled_expression, constraints (INPUT only), presentation.",
                      "items": {"type": "object"}
                    },
                    "confirm_new_root": {"type": "boolean", "description": "Set true ONLY after a person has agreed to a new top-level folder; the refusal names the roots that exist. 'test/' never needs it."}
                  },
                  "additionalProperties": false
                }
}
```

**Permission:** `parameter_set.create`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}`. **Errors:** the §13.20 validation family (the full failure list), `parameter.validation.new_root_requires_confirmation`, `parameter.authoring.disabled` on a receiver.

#### 6.2.47 `parameter_sets_update`

Edit a set by id: the first change after a release opens a DRAFT (copy-on-write), later updates overwrite it in place. `expected_hash` is the `body_hash` read from `parameter_sets_get` — a mismatch is a 409 conflict; re-read and rebase. The document is validated in full; the expensive source re-proving (steps 5–6) runs only when the body actually changed. A set is never renamed.

```json
{
  "name": "parameter_sets_update",
  "description": "Edit a parameter set by ID: the first change after a release opens a DRAFT (copy-on-write); later updates overwrite that same draft in place. Requires expected_hash: the body_hash you read from parameter_sets_get (or the previous update's result) for the version this edit is based on — a mismatch is a 409 conflict; re-read and rebase. The document is validated in full, and steps 5–6 (the source dry run and metadata execution) run only when the body actually changed. A set is never renamed: a document naming another set is refused.",
  "inputSchema": {
                  "type": "object",
                  "required": ["id", "expected_hash", "name", "display_name", "parameters"],
                  "properties": {
                    "id": {"type": "string", "format": "uuid"},
                    "expected_hash": {"type": "string", "description": "The body_hash of the version this edit is based on. A mismatch is a 409 conflict; re-read and rebase."},
                    "name": {"type": "string", "description": "Must equal the stored name — a set is never renamed."},
                    "display_name": {"type": "string"},
                    "description": {"type": "string"},
                    "parameters": {"type": "array", "items": {"type": "object"}}
                  },
                  "additionalProperties": false
                }
}
```

**Permission:** `parameter_set.update`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}` — the draft's hash is the next call's `expected_hash`.

#### 6.2.48 `parameter_sets_evaluate`

Evaluate a set by id (§6.2's tool face of the record's §5): submit EVERY parameter's current value (`selections`, wire-encoded, `MULTI` as an array; the first render sends `{}`) and receive the whole set re-rendered — `parameters[]` with each definition, its `dependents` and its `state` (`value`, `origin`, `computed_default`, `reset`, `hidden`, `disabled`, `options`, `errors`), plus `values`, the consumer payload. There is no client dependency logic: a stale child walks the server's selection priority (`reset: true`, never an error). The version resolves to the SERVED one unless `version` is passed; a DRAFT may be evaluated by its number — and then the 139 gate's twin applies: a draft whose pinned DRAFT template changed after this key's last `templates_render` of it is refused `parameter.evaluate.template_unrendered` (the fact is read from the same audit table the pipeline execute gate reads — the key's last render of that template id). An unknown `selections` key refuses the whole request (`unknown_parameter`).

```json
{
  "name": "parameter_sets_evaluate",
  "description": "Evaluate a parameter set by ID: submit EVERY parameter's current value (wire-encoded for its type, a MULTI as an array) and receive the WHOLE set re-rendered — every parameter's options, resolved value, origin, computed_default and reset flag, plus values, the consumer payload. The first render sends selections:{} and the server initialises the form. There is no client dependency logic: a stale child walks the server's selection priority (never an error — reset:true says so). The version resolves to the SERVED one unless you pass version; a DRAFT may be evaluated by its number — but a draft whose pinned DRAFT template changed after this key's last templates_render of it is refused parameter.evaluate.template_unrendered (render first, then evaluate). An unknown selections key refuses the whole request.",
  "inputSchema": {
                  "type": "object",
                  "required": ["id"],
                  "properties": {
                    "id": {"type": "string", "format": "uuid"},
                    "version": {"type": "integer", "description": "A specific version to evaluate. Absent: the SERVED version (current_version). A DRAFT may be evaluated by its own number."},
                    "selections": {
                      "type": "object",
                      "description": "Every parameter's current value keyed by parameter name — the first render sends {}. null and [] mean 'nothing chosen' and walk the selection priority. Unknown keys refuse the whole request.",
                      "additionalProperties": true
                    }
                  },
                  "additionalProperties": false
                }
}
```

**Permission:** `parameter_set.evaluate` — a VIEWER row (the fan-out's callers include every viewer, C24). Not a writer: nothing is stored. **Returns:** the §5.3 response verbatim (`id`, `name`, `version`, `valid`, `org`, `values`, `parameters[]`). **Errors:** whole-request — `parameter.evaluate.unknown_parameter`, `.timeout` (504), `.response_too_large` (413), `.template_unrendered`; per-parameter — in `state.errors[]` of a 200 (`valid: false`).

#### 6.2.49 `parameter_sets_purge_draft`

Hard-delete a set's DRAFT by id (versioning §5.4): the draft row is deleted, never restorable; if it is the set's ONLY version the set goes with it. `expected_hash` guards the purge. A RELEASED version is never touched here.

```json
{
  "name": "parameter_sets_purge_draft",
  "description": "Hard-delete a parameter set's DRAFT by ID (versioning §5.4): the draft row is deleted, never restorable. Requires expected_hash — the body_hash you read from parameter_sets_get — so you purge the draft you actually looked at. If the draft is the set's ONLY version, the set goes with it. A RELEASED version is never touched here (discard it over REST if that is the intent). A draft a dashboard pins is refused parameter.in_use, naming the dashboards (#320). This is a write: it is audited as one.",
  "inputSchema": {
                  "type": "object",
                  "required": ["id", "expected_hash"],
                  "properties": {
                    "id": {"type": "string", "format": "uuid"},
                    "expected_hash": {"type": "string", "description": "The draft's body_hash. A mismatch is a 409 conflict; re-read first."}
                  },
                  "additionalProperties": false
                }
}
```

**Permission:** `parameter_set.version.manage`. **Mutating** — the `mcp.tool.write` audit row is the trace of who purged it. **Returns:** `{id, purged: true}`. **Errors:** `parameter.not_found`, `parameter.version.not_draft` (no draft), `parameter.version.conflict` (stale hash), `parameter.in_use` (a dashboard pins the draft — #320; `details.pinned_by` names the dashboard versions, and a draft that is the set's only version asks for every stored dashboard version, discarded included).

#### 6.2.50 `visualizations_list`

Browse ONE level of the key's pinned workspace's visualization tree (`prefix` — the `parameter_sets_list` shape; names are folder paths, P24; an absent or empty `prefix` is the roots). Every row carries the visualization's `id` (what the other `visualizations_*` tools take), `name`, `display_name`, `version`, `status`, `current_version` and `used_by` — the dashboards that pin it, as `name@version` (D30): every live one under the whole view, only the dashboards the key's lens admits for a promoter. A promoter's key sees only the visualizations a released dashboard its lens admits pins (rest-api §23.1).

```json
{
  "name": "visualizations_list",
  "description": "List the visualizations of the key's pinned workspace by BROWSING one level of the name tree. A visualization is a versioned chart, table or KPI bound to named inputs, reusable across dashboards; every row carries the id the other visualizations_* tools take, and used_by — the dashboards that pin it (name@version). Names are FOLDER PATHS (acme/visualizations/monthly_revenue): prefix:\"\" lists the roots, prefix:\"acme\" what is directly under acme; folders and visualizations are answered apart. A promoter's key sees only the visualizations a released dashboard its lens admits pins.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "prefix": {
        "type": "string",
        "description": "Browse ONE level of the visualization name tree at this prefix. Empty string (or absent) browses the roots."
      },
      "limit": {
        "type": "integer",
        "default": 50,
        "maximum": 200
      }
    }
  }
}
```

**Permission:** `visualization.read` — every role (the promoter lensed). **Returns:** `{prefix, folders: [{path, segment, visualization_count}], visualizations: [{id, name, display_name, version, status, current_version, used_by: ["name@version"]}], returned}`. **Cost:** one pin scan per row (`limit` ≤ 200).

#### 6.2.51 `visualizations_get`

Read one visualization by id: the WORKING version's full document (renderer, inputs, transform, config, bindings, presentation, tests), its lifecycle state (`version`, `status`, `body_hash`, `current_version`, the `draft` pointer) and `used_by`. Another workspace's visualization — or a lens-hidden one — answers `visualization.not_found`, never a permission error (§11A.1).

```json
{
  "name": "visualizations_get",
  "description": "Read one visualization by ID: the WORKING version's full document (renderer, inputs, transform, config, bindings, presentation, tests) and its lifecycle state — version and status name the returned row (the DRAFT when one exists, else the current release), current_version the latest release, body_hash what an update's expected_hash carries — plus used_by, the dashboards that pin it. A visualization of another workspace, or one the promoter lens hides, answers not-found.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The visualization's id (visualizations_list returns it)."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.read`. **Returns:** `{id, name, display_name, description, version, status, body_hash, current_version, draft?, document, used_by}` — `draft` is null under a promoter's narrowing lens. **Errors:** `visualization.not_found`.

#### 6.2.52 `visualizations_create`

Create a visualization: version 1 lands as a DRAFT (no tool releases anything), validated in FULL at save by the same strict reader and validator REST's `POST /api/v1/visualizations` uses — the seven `datapipelines.visualization.*` bounds, the renderer's configuration schema, every binding path (it must already exist in `config`) and column, the transform pin's contract against the inputs (a DRAFT pin is accepted), the test cases. The arguments ARE the document's keys; the 094 new-root confirmation applies.

```json
{
  "name": "visualizations_create",
  "description": "Create a visualization: version 1 lands as a DRAFT (a human releases it; no tool releases anything). The document is validated in FULL at save — the renderer's configuration schema, every binding path (it must already exist in config) and column, the transform pin's contract against the inputs, the test cases — and a refusal names every failing path. A NEW top-level folder is refused until you confirm it: reuse an existing root, or ask the person first and then pass confirm_new_root: true. Returns the id the other tools take and the body_hash an update needs.",
  "inputSchema": {
    "type": "object",
    "required": [
      "name",
      "display_name",
      "renderer",
      "inputs",
      "config"
    ],
    "properties": {
      "name": {
        "type": "string",
        "description": "Folder-path name (2-10 segments, lower-case): acme/visualizations/monthly_revenue. Never renamed."
      },
      "display_name": {
        "type": "string",
        "description": "Human label, 1-120 characters."
      },
      "description": {
        "type": "string",
        "description": "Optional; 2000 characters max."
      },
      "renderer": {
        "type": "object",
        "description": "{kind, version}: kind is plotly, table or kpi (html and svg are reserved and refused); version is the renderer major the host must provide, as a string (\"4\")."
      },
      "inputs": {
        "type": "object",
        "description": "Named input contracts: {<input>: {columns: [{name, type, nullable}]}}; type is a LogicalType (STRING, INTEGER, DECIMAL, DATE, TIMESTAMP, BOOLEAN, ...). At most 8 inputs, 256 columns each."
      },
      "transform": {
        "type": "object",
        "description": "Optional: {template: {name, version}, inputs: {<contract input>: <visualization input>}} — a pinned SCRIPT template whose declared input columns equal the named inputs' columns. Absent: the renderer binds one input directly."
      },
      "config": {
        "type": "object",
        "description": "The renderer's native configuration, stored verbatim and validated against its schema (plotly: a non-empty data array of supported traces). At most 262,144 bytes."
      },
      "bindings": {
        "type": "object",
        "description": "Optional: {<path into config, e.g. data[0].y>: <column of the transform's output, or of the single input>}. Each path must already exist in config; its value is replaced by the column's values at render."
      },
      "presentation": {
        "type": "object",
        "description": "Optional: {title, tokens} — tokens name theme tokens, never colours."
      },
      "tests": {
        "type": "object",
        "description": "{cases: [{name, fixtures: {<input>: [rows]}, assertions: [{kind}]}]}; kind is rendered, trace_count (with equals), no_console_errors, text_visible (with text), no_data or value_visible (with text). A release needs at least one case."
      },
      "confirm_new_root": {
        "type": "boolean",
        "description": "Set true ONLY after a person has agreed to a new top-level folder; the refusal names the roots that exist. 'test/' never needs it."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.create`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}`. **Errors:** `visualization.validation.*` (every failure in `details.failures`; `name_taken` 409), `visualization.validation.new_root_requires_confirmation` (`details.existing_roots`), `visualization.authoring.disabled`.

#### 6.2.53 `visualizations_update`

The hash-preconditioned draft write (`parameter_sets_update`'s twin): the first change after a release opens a DRAFT (copy-on-write), later updates overwrite it in place. The whole document is sent and validated in full; a visualization is never renamed.

```json
{
  "name": "visualizations_update",
  "description": "Edit a visualization by ID: the first change after a release opens a DRAFT (copy-on-write); later updates overwrite that same draft in place. Requires expected_hash — the body_hash you read from visualizations_get (or the previous update's result) for the version this edit is based on; a mismatch is a 409 conflict: re-read and rebase. The whole document is sent and validated in full. A visualization is never renamed: a document naming another is refused.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "expected_hash",
      "name",
      "display_name",
      "renderer",
      "inputs",
      "config"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid"
      },
      "expected_hash": {
        "type": "string",
        "description": "The body_hash of the version this edit is based on. A mismatch is a 409 conflict; re-read and rebase."
      },
      "name": {
        "type": "string",
        "description": "Folder-path name (2-10 segments, lower-case): acme/visualizations/monthly_revenue. Never renamed."
      },
      "display_name": {
        "type": "string",
        "description": "Human label, 1-120 characters."
      },
      "description": {
        "type": "string",
        "description": "Optional; 2000 characters max."
      },
      "renderer": {
        "type": "object",
        "description": "{kind, version}: kind is plotly, table or kpi (html and svg are reserved and refused); version is the renderer major the host must provide, as a string (\"4\")."
      },
      "inputs": {
        "type": "object",
        "description": "Named input contracts: {<input>: {columns: [{name, type, nullable}]}}; type is a LogicalType (STRING, INTEGER, DECIMAL, DATE, TIMESTAMP, BOOLEAN, ...). At most 8 inputs, 256 columns each."
      },
      "transform": {
        "type": "object",
        "description": "Optional: {template: {name, version}, inputs: {<contract input>: <visualization input>}} — a pinned SCRIPT template whose declared input columns equal the named inputs' columns. Absent: the renderer binds one input directly."
      },
      "config": {
        "type": "object",
        "description": "The renderer's native configuration, stored verbatim and validated against its schema (plotly: a non-empty data array of supported traces). At most 262,144 bytes."
      },
      "bindings": {
        "type": "object",
        "description": "Optional: {<path into config, e.g. data[0].y>: <column of the transform's output, or of the single input>}. Each path must already exist in config; its value is replaced by the column's values at render."
      },
      "presentation": {
        "type": "object",
        "description": "Optional: {title, tokens} — tokens name theme tokens, never colours."
      },
      "tests": {
        "type": "object",
        "description": "{cases: [{name, fixtures: {<input>: [rows]}, assertions: [{kind}]}]}; kind is rendered, trace_count (with equals), no_console_errors, text_visible (with text), no_data or value_visible (with text). A release needs at least one case."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.update`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}`. **Errors:** `visualization.not_found`, `visualization.version.conflict` (stale `expected_hash` — re-read and rebase), `visualization.validation.*` (`name_invalid` / `immutable` for another name).

#### 6.2.54 `visualizations_purge_draft`

Hard-delete a visualization's DRAFT by id (versioning §5.4): never restorable; the sole draft takes the visualization with it. `expected_hash` guards the purge. A draft a LIVE dashboard version pins is refused. A RELEASED version is never touched here.

```json
{
  "name": "visualizations_purge_draft",
  "description": "Hard-delete a visualization's DRAFT by ID (versioning §5.4): the draft row is deleted, never restorable. Requires expected_hash — the draft's body_hash from visualizations_get — so you purge the draft you looked at. If the draft is the ONLY version, the visualization goes with it. A draft a live dashboard pins is refused (visualization.version.pinned, naming the dashboards). A RELEASED version is never touched here. This is a write: it is audited as one.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "expected_hash"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The visualization's id (visualizations_list returns it)."
      },
      "expected_hash": {
        "type": "string",
        "description": "The draft's body_hash. A mismatch is a 409 conflict; re-read first."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.version.manage`. **Mutating** — the `mcp.tool.write` audit row is the trace of who purged it. **Returns:** `{id, purged: true}`. **Errors:** `visualization.not_found`, `visualization.version.not_draft`, `visualization.version.conflict`, `visualization.version.pinned` (`details.pinned_by`).

#### 6.2.55 `dashboards_list`

Browse ONE level of the dashboard tree, the `visualizations_list` shape. A promoter's key sees only RELEASED dashboards whose every source pipeline its pipeline lens admits (rest-api §23.1).

```json
{
  "name": "dashboards_list",
  "description": "List the dashboards of the key's pinned workspace by BROWSING one level of the name tree. A dashboard pins exact pipeline versions as sources, maps their results onto pinned visualizations' inputs and lays them out; every row carries the id the other dashboards_* tools take. Names are FOLDER PATHS (acme/dashboards/revenue_overview): prefix:\"\" lists the roots. A promoter's key sees only released dashboards whose every source pipeline its lens admits.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "prefix": {
        "type": "string",
        "description": "Browse ONE level of the dashboard name tree at this prefix. Empty string (or absent) browses the roots."
      },
      "limit": {
        "type": "integer",
        "default": 50,
        "maximum": 200
      }
    }
  }
}
```

**Permission:** `dashboard.read` — every role (the promoter lensed). **Returns:** `{prefix, folders: [{path, segment, dashboard_count}], dashboards: [{id, name, display_name, version, status, current_version}], returned}`.

#### 6.2.56 `dashboards_get`

Read one dashboard by id: the WORKING version's full document and lifecycle state, plus `dependencies` — each pinned visualization occurrence's version status and each source pipeline version's status and `read_only` verdict as they are NOW, each read through the caller's lens (a hidden or absent pin answers `status: null` alike; one read per pin, bounded by the reader's 50 visualizations) — and `last_refresh`, the CALLER's own latest refresh of the dashboard (#10 L2) — `{refresh_id, dashboard_version, status, started_at, finished_at}` — or `null` when they have none. It names nobody else's refresh, whatever their role, and carries no selection; an MCP key acts as its OWN identity (keys v2 A13) and a refresh belongs to a person's session until the `dashboard` key kind (L5), so through a key it is `null` today; no tool refreshes a dashboard (the runtime is a person's surface until the `dashboard` key kind, L5).

```json
{
  "name": "dashboards_get",
  "description": "Read one dashboard by ID: the WORKING version's full document and lifecycle state (version, status, body_hash for an update's expected_hash, current_version), plus dependencies — each pinned visualization's version status and each source pipeline version's status and read_only verdict as they are NOW (a status of null: absent, or not visible to this key) — and last_refresh, the caller's own latest refresh of it (null when they have none). No tool refreshes a dashboard. A dashboard of another workspace, or one the promoter lens hides, answers not-found.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The dashboard's id (dashboards_list returns it)."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `dashboard.read`. **Returns:** `{id, name, display_name, description, version, status, body_hash, current_version, draft?, document, dependencies: {visualizations: [{occurrence, name, version, status}], pipelines: [{source, name, version, status, read_only}]}, last_refresh: {refresh_id, dashboard_version, status, started_at, finished_at} | null}`. **Errors:** `dashboard.not_found`.

#### 6.2.57 `dashboards_create`

Create a dashboard: version 1 lands as a DRAFT, validated in FULL against the pins as they are now (every source version DRAFT or RELEASED and read-only with its required parameters bound; every visualization input mapped; one namespace for occurrences, groups, actions, controls and the set's parameters; every occurrence and control placed on the grid). Pinned dependencies may be live drafts during authoring and explicit draft preview; publication requires released dependencies. The arguments ARE the document's keys; the 094 new-root confirmation applies.

```json
{
  "name": "dashboards_create",
  "description": "Create a dashboard: version 1 lands as a DRAFT (a human releases it; no tool releases anything). The document is validated in FULL against the pins as they are now — every source pipeline version DRAFT or RELEASED and read-only with its required parameters bound, every visualization input mapped to a source, one namespace for occurrences, groups, actions, controls and the set's parameters, every occurrence and control placed on the 12-column grid — and a refusal names every failing path. A pinned dependency may be a live DRAFT at save and in explicit draft preview; publication requires releases. A NEW top-level folder is refused until you confirm it: reuse an existing root, or ask the person first and then pass confirm_new_root: true.",
  "inputSchema": {
    "type": "object",
    "required": [
      "name",
      "display_name",
      "visualizations",
      "layout"
    ],
    "properties": {
      "name": {
        "type": "string",
        "description": "Folder-path name (2-10 segments, lower-case): acme/dashboards/revenue_overview. Never renamed."
      },
      "display_name": {
        "type": "string",
        "description": "Human label, 1-120 characters."
      },
      "description": {
        "type": "string",
        "description": "Optional; 2000 characters max."
      },
      "parameter_set": {
        "type": "object",
        "description": "Optional: {name, version} — the parameter set whose controls the dashboard shows."
      },
      "sources": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "[{name, pipeline: {name, version}, parameters: {<pipeline parameter>: {parameter: <set parameter>} or {value: <literal>}}}] — each pinned version DRAFT or RELEASED and read-only during authoring; publication requires RELEASED; every required pipeline parameter bound."
      },
      "visualizations": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "[{name, type: \"visualization\", visualization: {name, version}, inputs: {<visualization input>: {source: <source name>}}, timeout_seconds}] — at most 50; every named input of the pinned visualization mapped."
      },
      "groups": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"group\", members: [<object or parameter names>]}]."
      },
      "actions": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"refresh\", scope: all or targets, targets: [<visualization occurrence names>] (iff scope is targets), initial}]."
      },
      "action_controls": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"action_control\", action: <action name>, label, parameter}] — parameter binds the action to a LEAF parameter's control."
      },
      "parameter_scopes": {
        "type": "object",
        "description": "Optional: {<set parameter>: [<group names>]} — a scope may not omit a group that consumes the parameter."
      },
      "parameter_state": {
        "type": "object",
        "description": "Optional: {dashboard: {visible, enabled}, parameters: {<name>: {visible, enabled}}}; each inherit, force_true or force_false."
      },
      "outgoing_overrides": {
        "type": "object",
        "description": "Optional: {<source name>: {<pipeline parameter>: {value: <literal>}}} — the value the pipeline receives whatever the control showed."
      },
      "layout": {
        "type": "object",
        "description": "{grid: [{name, x, y, w, h}], columns: 12, breakpoint_px, parameter_set: {position}, parameter_placements: {<parameter>: {group}}} — every occurrence and control placed exactly once (a group at most once)."
      },
      "timeouts": {
        "type": "object",
        "description": "Optional: {refresh_seconds} — at most 900."
      },
      "confirm_new_root": {
        "type": "boolean",
        "description": "Set true ONLY after a person has agreed to a new top-level folder; the refusal names the roots that exist. 'test/' never needs it."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `dashboard.create`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}`. **Errors:** `dashboard.validation.*` (every failure in `details.failures`; `name_taken` 409), `dashboard.validation.new_root_requires_confirmation`, `dashboard.authoring.disabled`.

#### 6.2.58 `dashboards_update`

The hash-preconditioned draft write — `visualizations_update`'s twin.

```json
{
  "name": "dashboards_update",
  "description": "Edit a dashboard by ID: the first change after a release opens a DRAFT (copy-on-write); later updates overwrite that same draft in place. Requires expected_hash — the body_hash you read from dashboards_get (or the previous update's result); a mismatch is a 409 conflict: re-read and rebase. The whole document is sent and validated in full. A dashboard is never renamed.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "expected_hash",
      "name",
      "display_name",
      "visualizations",
      "layout"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid"
      },
      "expected_hash": {
        "type": "string",
        "description": "The body_hash of the version this edit is based on. A mismatch is a 409 conflict; re-read and rebase."
      },
      "name": {
        "type": "string",
        "description": "Folder-path name (2-10 segments, lower-case): acme/dashboards/revenue_overview. Never renamed."
      },
      "display_name": {
        "type": "string",
        "description": "Human label, 1-120 characters."
      },
      "description": {
        "type": "string",
        "description": "Optional; 2000 characters max."
      },
      "parameter_set": {
        "type": "object",
        "description": "Optional: {name, version} — the parameter set whose controls the dashboard shows."
      },
      "sources": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "[{name, pipeline: {name, version}, parameters: {<pipeline parameter>: {parameter: <set parameter>} or {value: <literal>}}}] — each pinned version DRAFT or RELEASED and read-only during authoring; publication requires RELEASED; every required pipeline parameter bound."
      },
      "visualizations": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "[{name, type: \"visualization\", visualization: {name, version}, inputs: {<visualization input>: {source: <source name>}}, timeout_seconds}] — at most 50; every named input of the pinned visualization mapped."
      },
      "groups": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"group\", members: [<object or parameter names>]}]."
      },
      "actions": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"refresh\", scope: all or targets, targets: [<visualization occurrence names>] (iff scope is targets), initial}]."
      },
      "action_controls": {
        "type": "array",
        "items": {
          "type": "object"
        },
        "description": "Optional: [{name, type: \"action_control\", action: <action name>, label, parameter}] — parameter binds the action to a LEAF parameter's control."
      },
      "parameter_scopes": {
        "type": "object",
        "description": "Optional: {<set parameter>: [<group names>]} — a scope may not omit a group that consumes the parameter."
      },
      "parameter_state": {
        "type": "object",
        "description": "Optional: {dashboard: {visible, enabled}, parameters: {<name>: {visible, enabled}}}; each inherit, force_true or force_false."
      },
      "outgoing_overrides": {
        "type": "object",
        "description": "Optional: {<source name>: {<pipeline parameter>: {value: <literal>}}} — the value the pipeline receives whatever the control showed."
      },
      "layout": {
        "type": "object",
        "description": "{grid: [{name, x, y, w, h}], columns: 12, breakpoint_px, parameter_set: {position}, parameter_placements: {<parameter>: {group}}} — every occurrence and control placed exactly once (a group at most once)."
      },
      "timeouts": {
        "type": "object",
        "description": "Optional: {refresh_seconds} — at most 900."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `dashboard.update`. **Mutating.** **Returns:** `{id, name, version, status, body_hash}`. **Errors:** `dashboard.not_found`, `dashboard.version.conflict`, `dashboard.validation.*`.

#### 6.2.59 `dashboards_purge_draft`

Hard-delete a dashboard's DRAFT by id (versioning §5.4); the sole draft takes the dashboard. `expected_hash` guards the purge.

```json
{
  "name": "dashboards_purge_draft",
  "description": "Hard-delete a dashboard's DRAFT by ID (versioning §5.4): the draft row is deleted, never restorable. Requires expected_hash — the draft's body_hash from dashboards_get. If the draft is the ONLY version, the dashboard goes with it. A RELEASED version is never touched here. This is a write: it is audited as one.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "expected_hash"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The dashboard's id (dashboards_list returns it)."
      },
      "expected_hash": {
        "type": "string",
        "description": "The draft's body_hash. A mismatch is a 409 conflict; re-read first."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `dashboard.version.manage`. **Mutating.** **Returns:** `{id, purged: true}`. **Errors:** `dashboard.not_found`, `dashboard.version.not_draft`, `dashboard.version.conflict`.

#### 6.2.60 `dashboards_validate`

§3.2's rules against the dependencies' CURRENT state for the WORKING version, no write — the agent's loop before a person releases (rest-api §23.2's `POST /api/v1/dashboards/{id}/validate`). The verdict is the answer: `valid` and every failure in the refusal's `details.failures` shape.

```json
{
  "name": "dashboards_validate",
  "description": "Validate a dashboard by ID against its dependencies as they are NOW, without writing anything: the WORKING version (the draft, else the current release) is checked in full, and the answer is the verdict — valid, and every failure with its code, path and message. Run it after the pipelines, the parameter set or the visualizations a draft pins have changed, before asking a person to release. An authoring verb: a promoter's key is refused.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The dashboard's id (dashboards_list returns it)."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `dashboard.update` — an AUTHOR verb (owner ruling 2026-09-29; the implementation spec's §7 said `dashboard.read`): the validator reads the pinned pipelines', set's and visualizations' statuses unlensed (the L1a security pass's O5), so a promoter's key is refused by the dispatcher. **Not mutating** (nothing is written). **Returns:** `{id, name, version, body_hash, valid, failures: [{code, path, message, details}]}`. **Errors:** `dashboard.not_found`.

#### 6.2.61 `visualizations_test_start`

Open a test session on a visualization's WORKING version (the draft when one exists) — the REST `POST /api/v1/visualizations/{id}/tests/sessions` twin (#353; the implementation spec §11.2). The answer carries `preview_url`: the page that serves the version's saved test fixtures through the real dashboard runtime — no pipeline, no datasource, no login. The preview capability rides inside that URL as `?session=`; it is shown ONCE, it is the agent's browser's only credential, and it dies when the session's results are submitted or at `expires_at` (`datapipelines.visualization.session-ttl-minutes`, 60). An edit to the visualization voids the session. The SAME key must submit it — a session is its starter's.

```json
{
  "name": "visualizations_test_start",
  "description": "Start a test session for a visualization by ID, on its WORKING version (the draft when one exists). Returns preview_url — open it in a browser: it serves the visualization's saved test fixtures through the real renderer, with no login (the token inside the URL is the only credential; it is shown once and dies when you submit or at expires_at) — plus the case names you must give verdicts for and expires_at. Check every case in the page, then call visualizations_test_submit with the SAME key. Editing the visualization voids the session.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The visualization's id (visualizations_list returns it). It needs at least one test case."
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.update`. **Mutating** — a run row is written. **Returns:** `{session_id, run_id, visualization_id, version, body_hash, preview_url, expires_at, cases: [names]}` — `preview_url` is absolute when the deployment sets `datapipelines.auth.base-url`, else root-relative (resolve it against the origin you reach the app on). **Errors:** `visualization.not_found`, `visualization.validation.test_case_invalid` (`details.reason: no_cases` — the version declares no case), `visualization.version.conflict`.

#### 6.2.62 `visualizations_test_submit`

Complete a session with per-case verdicts — the REST `POST /api/v1/visualizations/{id}/tests/sessions/{sessionId}/results` twin, through the same reader. The server runs the §11.3 mechanical test at this moment and derives the status: `GREEN` when every case's verdict is `green` AND the mechanical test passes; `RED` when any verdict is `red` or the mechanical test fails; `INCOMPLETE` when a case has no verdict. The preview capability is revoked. On a GREEN run only, the answer carries the single-use screenshot upload — the agent POSTs one PNG or WebP (≤ 4 MiB, raw body) to `upload.url` with `upload.token` in the `upload.header` header ([REST API §22.2](/docs/rest-api.md#222-routes)); an `mcp` key reaches no REST route, so the upload is authenticated by that token alone. No tool releases: a person releases the visualization in the UI after review.

```json
{
  "name": "visualizations_test_submit",
  "description": "Submit the verdicts of a test session started with visualizations_test_start — with the SAME key that started it. Give every case a verdict (green or red, optional notes): a case without one makes the run INCOMPLETE. The server re-runs its own mechanical test now; the run is GREEN only when every verdict is green AND that test passes. A GREEN answer carries upload: {url, header, token, expires_at} — POST one PNG or WebP screenshot (at most 4 MiB, raw bytes) to url with the token in that header, once, from your browser or HTTP client. No tool releases: a person releases the visualization in the UI after review.",
  "inputSchema": {
    "type": "object",
    "required": [
      "id",
      "session_id",
      "cases"
    ],
    "properties": {
      "id": {
        "type": "string",
        "format": "uuid",
        "description": "The visualization's id."
      },
      "session_id": {
        "type": "string",
        "format": "uuid",
        "description": "The session_id visualizations_test_start returned."
      },
      "cases": {
        "type": "array",
        "description": "One verdict per case name visualizations_test_start listed; an unknown or repeated name is refused.",
        "items": {
          "type": "object",
          "required": [
            "name",
            "verdict"
          ],
          "properties": {
            "name": {
              "type": "string",
              "description": "The case name, exactly as listed."
            },
            "verdict": {
              "type": "string",
              "enum": [
                "green",
                "red"
              ],
              "description": "green when every assertion of the case held in the preview, else red."
            },
            "notes": {
              "type": "string",
              "description": "What you saw; 2000 characters max."
            }
          },
          "additionalProperties": false
        }
      },
      "environment": {
        "type": "object",
        "description": "Optional claims about where you looked — a closed set of short strings (120 characters max each).",
        "properties": {
          "theme": {
            "type": "string"
          },
          "viewport": {
            "type": "string"
          },
          "browser": {
            "type": "string"
          },
          "locale": {
            "type": "string"
          },
          "renderer_version": {
            "type": "string"
          }
        },
        "additionalProperties": false
      }
    },
    "additionalProperties": false
  }
}
```

**Permission:** `visualization.update`. **Mutating** — the run is completed. **Returns:** `{session_id, run_id, status, completed_at, mechanical, upload: {url, header, token, expires_at} | null}` — `mechanical` is the §11.3 report (`ok`, `failures` — the first 100 — and `failures_dropped`, the count beyond them). **Errors:** `visualization.validation.body_invalid` (`details.reason`: `unknown_case`, `duplicate_case`, `notes_too_long`, `verdict_invalid`, `environment_unknown`, `environment_field_invalid`, …, `details.path` names it), `visualization.test.session_not_found` (unknown, another key's, or a session of another visualization — never told apart), `visualization.test.session_expired` (`details.reason`: `not_running` — already submitted or swept, `content_moved_on`, `version_gone`).

### 6.3 Tool result schema

All tool results follow this envelope:

```json
{
  "content": [
    {
      "type": "text",
      "text": "..."        // JSON-stringified payload for tools returning JSON
    }
  ],
  "isError": false
}
```

On error:

```json
{
  "content": [
    {
      "type": "text",
      "text": "{\"error\": {\"code\": \"...\", \"message\": \"...\", ...}}"
    }
  ],
  "isError": true
}
```

The inner JSON matches the [REST API error envelope's `error` object](/docs/rest-api.md#42-error-envelope) — same codes, same shape, so agents see consistent errors whether they come via REST or MCP.

**Execution failures carry the full record (057).** When the error is an execution failure — a `pipelines_execute` whose pipeline failed — the `error` object additionally carries `node`, `sql` and `exception`: the §6.2.14 failure record, so the agent that just ran the pipeline sees the root cause without a second call. Every other error keeps exactly the shape above.

Every tool result — success or error — carries the request's `correlation_id` in its `_meta`, echoing the `DP-Correlation-Id` of the underlying request so a user can hand an agent's output straight to an operator and have it traced ([Observability §9](/docs/observability.md)).

---

## 7. Resource Surface

Resources are entities the agent can read as "files." Useful for agents that want to inspect definitions without calling tools.

### 7.1 Resource URI scheme

```
datapipelines://pipelines/{id}                                 → latest version, full body
datapipelines://pipelines/{id}/versions/{version}              → specific version
datapipelines://pipelines/{id}/parameters                      → the pipeline's parameter declarations only
datapipelines://templates/{id}                                 → latest version
datapipelines://templates/{id}/versions/{version}
datapipelines://datasources/{name}                             → metadata, no password
datapipelines://datasources                                    → list
datapipelines://executions/{execution_id}                      → execution metadata
datapipelines://executions/{execution_id}/events               → SSE event replay as text
datapipelines://docs/skill                                     → the agent skill's operating core (Markdown)
datapipelines://docs/skill/{reference}                         → one reference file of the skill
```

The `docs` kind is not an entity: it is the manual the server ships (§7.2.4). Every other
form addresses stored content and is workspace-scoped; `docs/*` is the same bytes for every
caller on a given build.

### 7.2 Resource examples

#### 7.2.1 `datapipelines://pipelines/{id}`

Returns the pipeline JSON body, content-type `application/json`.

#### 7.2.2 `datapipelines://templates/{id}/versions/{version}`

Returns the template body (Freemarker SQL), content-type `text/x-freemarker-sql`.

**`{id}` contains slashes.** A template id is a folder path ([Template Hierarchy §4.1](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md#41-grammar)) and, since 077, always at least two segments — so the id is **every segment after `templates`**, not one. `datapipelines://templates/acme/finance/daily_orders.sql` is the latest version of `acme/finance/daily_orders.sql`. The `versions/{version}` suffix is recognised by the LAST two segments, never by position, so a folder named `versions` stays a folder: `…/templates/acme/versions/report.sql` is the template `acme/versions/report.sql`, and `…/templates/acme/versions/report.sql/versions/2` is its version 2. (Corrected in 077: the parser had required exactly two segments since 043, so every hierarchical id read as not-found.)

#### 7.2.3 `datapipelines://datasources/{name}`

Returns datasource metadata as JSON, with the password field redacted. Workspace-scoped like every datasource read (§2 principle 6): a name bound to another workspace resolves as not-found; `datapipelines://datasources` lists exactly the pinned workspace's visible set (bound + global).

#### 7.2.4 `datapipelines://docs/skill`

Returns the agent skill's `SKILL.md`, content-type `text/markdown` — the same bytes the
deployment serves at `GET /skill.md` and the same file the repository holds at
`modules/mcp-server/src/main/resources/skill/core.md`, the manual's core rendered at boot (§15). `datapipelines://docs/skill/{reference}`
returns one file of `references/` by name, with or without the `.md` suffix
(`…/skill/templates` and `…/skill/templates.md` are the same resource); an unknown name is
`RESOURCE_NOT_FOUND` like any other unknown URI. `{reference}` is a NAME, never a path — it
is looked up in a map of packaged files, so no caller string reaches a file system.

### 7.3 Resource discovery

Agents use `resources/list` to discover URIs:

```json
{
  "method": "resources/list",
  "params": {
    "cursor": "eyJrIjoicGlwZWxpbmVzIiwibyI6MTAwfQ"    // optional; omit for the first page
  }
}
```

Returns a page of resource descriptors (URI, name, description, MIME type) plus `nextCursor`.

**Pagination is mandatory and normative:**

- **Page size is fixed at 100** descriptors. It is not client-controllable — an agent asking for "everything" must page.
- `cursor` is an **opaque server-issued token**. Clients MUST treat it as an opaque string: do not parse, construct, or persist it across server restarts. A cursor the server cannot decode → JSON-RPC `-32602` invalid params.
- The response omits `nextCursor` on the last page. Presence of `nextCursor` is the only "there is more" signal.
- Enumeration order is stable within a paging run (docs, then pipelines, then templates, then datasources, then executions; each by id).  The `docs` rows lead because they are the only constant-size kind — the skill plus one row per reference, identical on every server — so they cannot push an entity off a page, and an agent that lists resources at all meets the manual before it meets content. Entities created mid-run may be missed — `resources/list` is a discovery aid, not a consistent snapshot.

**Role filtering:** the listing is filtered to what the calling key's role may read — each family under its read permission (`pipeline.read`, `template.read`, `datasource.read`, `execution.read`, `docs.read`; a promoter's key lists no executions), and ownership rules apply to executions — the listing reads what `executions_list` reads (§6.2.13, #275: own runs plus every SCHEDULED run, or every run with `execution.read_all`) — **and to the key's pinned workspace** (workspaces design §5.2/§5.3: its pipelines/templates/executions, its bound datasources plus global ones), so two agents see different resource sets on the same server.

**Execution resources are windowed:** only executions from the **last 24 hours** are enumerated. Older executions remain readable by direct URI (`datapipelines://executions/{id}`) as long as their metadata exists in the Metadata DB — they are simply not listed, because an unbounded execution history would make `resources/list` useless (and enormous) on any busy instance. Result rows are governed by the much shorter result TTL regardless (§6.2.15).

### 7.4 No subscriptions in v1

We do not support `resources/subscribe` in v1. Resources change rarely enough that re-fetch on demand is sufficient. Subscription support is a v2 candidate (would let agents react to new pipeline versions, etc.).

---

## 8. Prompt Surface

Predefined prompts the agent can invoke via `prompts/get`. Useful for steering agents toward common workflows.

**Admission rule:** a prompt ships only if every step it instructs the agent to take is achievable with the 42 tools in §6.1 and the resources in §7. A prompt that depends on a tool we have not built is a scripted failure — it reads as a supported capability and dead-ends the agent partway through. All three prompts meet the bar (§8.1, §8.2, §8.3); §8.2 returned in v1.1 together with the introspection tools it depends on.

### 8.1 `analyze_pipeline`

```json
{
  "name": "analyze_pipeline",
  "description": "Guide the agent through analyzing a pipeline's structure, identifying potential issues, and suggesting improvements.",
  "arguments": {
    "type": "object",
    "required": ["pipeline_id"],
    "properties": {
      "pipeline_id": {"type": "string", "format": "uuid"}
    }
  }
}
```

Returns a prompt instructing the agent to fetch the pipeline definition (`pipelines_get`), read each referenced template (`templates_get`), preview the generated SQL (`templates_render`) against representative parameter values, check the SQL against the node's target dialect, look for performance issues, and report findings. Read-only: the prompt never instructs the agent to modify anything. Every step uses a v1 tool.

### 8.2 `create_pipeline_for_question`

**Shipped in v1.1** — returned together with the introspection tools it depends on (§6.2.16–18), which is what satisfies §8's admission rule: its schema-grounding step has an implementation, so the walkthrough cannot dead-end the agent or tempt it into hallucinating tables. (In v1 it was deliberately withheld for exactly that reason — a sequencing decision, not a rejection.)

```json
{
  "name": "create_pipeline_for_question",
  "description": "Guide the agent through building a pipeline that answers a natural-language question: discover the datasource, introspect its real schema, author the SQL template, create and execute the pipeline.",
  "arguments": {
    "type": "object",
    "required": ["question"],
    "properties": {
      "question": {"type": "string", "description": "The natural-language question to build a pipeline for (max 2000 characters)."}
    }
  }
}
```

Returns a prompt that walks the agent through:

1. `datasources_list` to pick the datasource holding the data the question needs.
2. `datasources_get_schemas` to see the schemas, then `datasources_get_tables(schema)` to list that schema's tables, then `datasources_get_columns` for **only the tables the SQL needs** — **never reference a table or column these tools did not return**; if the data is not there, the agent stops and says so instead of guessing.
3. `templates_create` for the SQL template, describing its expected variables in its description.
4. `pipelines_create` to assemble the pipeline.
5. `pipelines_execute` to run it and report the result.

The question is embedded between sentinel lines — `<<<QUESTION` and `QUESTION>>>`, each on its own line — that the instructions tell the agent to treat as the question to answer, never as instructions to follow. Containment instead of prohibition: quotes and newlines in the question cannot close or extend the block (a question cannot smuggle a line the agent might read as a step, because the fence only ends at the exact sentinel line), and a `question` containing either sentinel is refused with `-32602` — the fence cannot be forged from inside. The `question` argument is also length-capped at 2000 characters and refused when missing or blank; unlike §8.1/§8.3's UUID arguments it is free text by design (carrying the user's question is the feature), and the sentinel fence is the injection guard.

### 8.3 `debug_failed_execution`

```json
{
  "name": "debug_failed_execution",
  "description": "Guide the agent through diagnosing why an execution failed.",
  "arguments": {
    "type": "object",
    "required": ["execution_id"],
    "properties": {
      "execution_id": {"type": "string", "format": "uuid"}
    }
  }
}
```

Returns a prompt that walks the agent through reading the execution metadata and failed node's error (`executions_get`), comparing against recent executions of the same pipeline (`executions_list`), reading the pipeline and the failing node's template (`pipelines_get`, `templates_get`), re-rendering that template with the failed execution's parameters to see the exact SQL (`templates_render`), checking datasource reachability (`datasources_test`), and proposing a fix. Every step uses a v1 tool.

---

## 9. Error Handling

### 9.1 MCP-level errors

Protocol violations (malformed JSON-RPC, missing required fields, unsupported method):

```json
{
  "jsonrpc": "2.0",
  "id": "...",
  "error": {
    "code": -32602,
    "message": "Invalid params: missing required field 'id'."
  }
}
```

Standard JSON-RPC error codes (`-32700` parse error, `-32600` invalid request, `-32601` method not found, `-32602` invalid params, `-32603` internal error).

### 9.2 Application errors

All datapipelines.co application errors (auth failures, validation errors, execution failures) are returned as **tool-call results with `isError: true`**, not as JSON-RPC errors. This is the MCP convention — domain errors are content, not protocol errors.

The error payload inside the tool result matches the [REST API `error` object](/docs/rest-api.md#42-error-envelope) exactly:

```json
{
  "content": [
    {
      "type": "text",
      "text": "{\"error\":{\"code\":\"pipeline.node.datasource_connection_failed\",\"message\":\"Could not acquire connection to 'pg-prod'.\",\"user_message\":\"...\",\"details\":{...},\"doc_url\":\"...\"}}"
    }
  ],
  "isError": true
}
```

### 9.3 Transport errors

- HTTP 401 (`auth.api_key.missing` / `.invalid` / `.expired`) → the key is absent, revoked or expired. Retrying does not help; the user must supply a new key.
- HTTP 401 (`auth.principal_deactivated`) → the key's identity or MCP creator was deactivated; HTTP 404 (`auth.key_workspace_inactive`) → the workspace the key is pinned to was deactivated. Neither is a key problem: an administrator reactivating the user or the workspace restores the same key.
- A tool result with `isError` and `auth.role_required` → the key's OWN role (chosen at creation) does not hold the tool's permission; `details.required` names it and `details.held` is the key's role (§6.2, [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)). Retrying does not help and neither does a role change on the Keys page: the key is judged by its own role, so the fix is a key whose role holds the permission — create one under the subset rule ([Auth §7.4](/docs/auth.md#74-issuance)) and revoke this one. (`auth.key_issuer_role_lost` is never an MCP answer: an MCP key always carries a role of its own and refuses with `auth.role_required`; that code survives only on the context-judged path, where a key principal is judged through a workspace context — the promotion peer, [Auth §9](/docs/auth.md#9-auth-errors).)
- HTTP 429 (`rate_limit.exceeded`) → rate limited. Limits are **per-user**, shared across REST and MCP ([REST API §12](/docs/rest-api.md#12-rate-limiting)); honor `Retry-After` and back off.
- HTTP 429 (`rate_limit.unavailable`) → the limiter could not decide and refused the call (fail closed, [REST API §12.3](/docs/rest-api.md#123-when-the-limiter-itself-is-unavailable)). Not your budget: honor `Retry-After` and retry, and do not treat it as a signal to reduce your request rate permanently.
- HTTP 5xx → server error; agent should retry with backoff.

---

## 10. Logging

> **Not delivered in v1.** The v1 transport is stateless (§3.3) and answers `GET /mcp` with `405`, so there is no server-to-client stream — the server advertises no `logging` capability (§5.1) and emits none of the notifications below. This section defines their **shape** for the stateful transport that lands with v2 ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)); until then the authoritative per-node record is the `node_stats` array in a tool's final result. The rest of this section is v2-forward.

When emitted (v2), the server sends `notifications/message` per the MCP logging spec:

```json
{
  "method": "notifications/message",
  "params": {
    "level": "info",
    "logger": "datapipelines.executor",
    "data": {
      "message": "Node fetch_orders completed",
      "execution_id": "exec-uuid",
      "node_id": "fetch_orders",
      "duration_ms": 1266
    }
  }
}
```

Levels: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`.

Log notifications carry the `correlation_id` of the originating request.

Agents can use these for visibility into an execution while a `pipelines_execute` call is still blocking (§6.2.3) — they are the v1 stand-in for MCP progress notifications, which are deferred to v2 ([ROADMAP §3.7](/docs/roadmap.md#37-mcp-server)). They are advisory: delivery requires the client to have an open `GET /mcp` notification stream, and nothing in the execution contract depends on them. The authoritative per-node record is the `node_stats` array in the tool's final result.

---

## 11. Discovery

### 11.1 For end users

Users discover the MCP endpoint via the UI's "Connect an Agent" page, which exposes:

- The full MCP endpoint URL (`https://{host}/mcp`).
- The MCP key's home ([UI Screens](/docs/ui-screens.md); REST surface in [REST API §16.1](/docs/rest-api.md#161-api-keys-creation-is-on-the-keys-page--keys-v2)): the Keys page — the page must make creation (with the role choice), the prefix, the one-time copy of a migrated key's sealed secret and revoke-own findable, and state that the key's reach IS the role it was given.
- A copy-pasteable configuration snippet for common agents, using whichever header that client supports (`DP-API-Key` or `Authorization: Bearer dpk_...` — §3.2):
  - Claude Desktop: `mcpServers` JSON for `claude_desktop_config.json`.
  - Cursor: settings JSON.
  - Generic HTTP MCP client: connection details.

### 11.2 For agents

Agents discover the server's capabilities via the standard MCP `initialize` handshake. No out-of-band config required beyond endpoint URL + API key.

---

## 12. Open Questions / Future Additions

Out of scope for v1, tracked for future ([ROADMAP](/docs/roadmap.md) is the authoritative queue):

- **Dynamic per-pipeline tools**: register `pipeline_execute_{name}` tools for pipelines flagged as "agent-exposed," so an agent sees them by name rather than discovering them by listing. Flips `tools.listChanged` to `true` (§5.1). v2, [ROADMAP §3.7](/docs/roadmap.md#37-mcp-server).
- **Result streaming / progress notifications via MCP**: stream execution events through the MCP transport instead of returning them only in the final tool result — removes the blocking-call experience of §6.2.3. v2, [ROADMAP §3.7](/docs/roadmap.md#37-mcp-server).
- **Resource subscriptions**: `resources/subscribe` for live updates when pipelines/templates change. v2, [ROADMAP §3.7](/docs/roadmap.md#37-mcp-server).
- **Datasource CREATE, UPDATE and DELETE tools**: deliberate omission, not an oversight, and since 094 a standing rule rather than a case-by-case judgement — **no credential travels through an agent** ([§6.2.22](#6222-removed--no-datasource-writes-on-this-surface)). Registration briefly existed as `datasources_create` (068) and was removed; editing and deleting an established datasource are operator actions with a blast radius across every pipeline that references it. All three stay UI/REST-only (§4.1).
- **Sampling**: support server-initiated LLM completions (rare for this product; agents do their own LLM work).
- **OAuth support**: when multi-tenant SaaS deployment materializes.
- **MCP roots**: not applicable (we are not a filesystem tool).

---

## 13. Security Review Checklist

(This section is normative for the implementation.)

- [ ] Every MCP endpoint requires auth (no unauthenticated access).
- [ ] API key validated on every request, not just session establishment — via `DP-API-Key` **and** `Authorization: Bearer dpk_...`, both through the single [Auth §7.3](/docs/auth.md#73-validation-flow) path. No second, laxer code path for the Bearer form.
- [ ] Session JWTs (`dp_session` cookie, non-`dpk_` Bearer tokens) are **rejected** on `/mcp` — verify with a test that a valid browser session cannot call a tool.
- [ ] Key revocation and identity/creator/workspace deactivation take effect within the cache TTL (~60s) on `/mcp`; a member role change does not reach the key — its authority is its own chosen role (keys v2).
- [ ] The permission enforced per tool against the [Auth §7.6 catalog](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) — every tool walked with every role an MCP key can act with (viewer, promoter, author, workspace admin), admitted exactly where the role's column holds the tool's permission.
- [ ] Execution ownership enforced on `executions_get`, `executions_get_result`, and execution resources — a key of a member without `execution.read_all` cannot read another user's results.
- [ ] `resources/list` filtered by the caller's role (each family's read permission) and ownership (§7.3), not just paginated.
- [ ] Datasource passwords never included in tool results or resources; `datasources_test` failures do not echo credentials or JDBC URLs.
- [ ] Error messages do not leak credentials or internal network topology.
- [ ] Rate limiting enforced at the MCP layer — the same **per-user** limits as REST, shared across both surfaces (a user cannot double their budget by splitting traffic).
- [ ] `/mcp` is CSRF-exempt *because* it accepts no cookies — assert both halves; exemption without the cookie ban is a CSRF hole.
- [ ] All MCP traffic over TLS (enforced by deployment, not just recommended).
- [ ] Audit log records every tool call (tool name, caller, target entity, timestamp, success/failure) with the `correlation_id`.
- [ ] Every call to a catalog-declared **mutating** tool writes exactly one `mcp.tool.write` audit event — a node run that altered customer data must never be untraceable (§14).

---

## 14. Audit

> **Status:** normative (052, ruling R4 on T65). The same `audit_log` sink the [`auth.*` events](/docs/auth.md#101-events) use — no separate table, no execution rows, no UI history.

**The tool events are emitted at `McpToolDispatcher`** — the single dispatch choke point, so no tool can forget its own trace. **The resource event is emitted at `McpResourceReader.read`** (120), the one place every read passes through, for the same reason:

| Event | When | 
|---|---|
| `mcp.tool.called` | Every tool call, every outcome (success, domain error, invalid params, internal error, `permission_refused`) |
| `mcp.tool.write` | Exactly one per call to a tool the catalog declares **mutating**, emitted after the tool returns — on success and on failure alike. A §7.6 permission refusal never invoked the tool, so it writes no write event: the refusal is recorded by `mcp.tool.called` alone |
| `mcp.execution.launched` | One per `pipelines_execute` launch (107), emitted BEFORE the blocking run begins: key id + correlation id + pipeline/execution id. It exists so §6.2.35's same-credential rule can authorize cancelling an IN-FLIGHT execution — the end-of-call `mcp.tool.called` row only exists once the blocking call returns, which for this tool is after the execution is terminal |
| `mcp.resource.read` | Every `resources/read`, success or failure (120). Until this event the read side of the MCP surface left no trace at all — the audit of an agent's session could show 35 tool calls and silence about which documents it opened |

**Fields** (the two tool events identical; the resource event mirrors them): actor — `user_id` (the key's owner) and `key_id`; `tool`; `target` — the identifier-shaped argument only (execution/pipeline/template id or name); for version-aware tools the `version` the call named; for node runs the `node_id`; for the table-addressed schema tools (`datasources_get_columns` / `_get_tables` / `_get_table_stats`) the `table` (and the `namespace` segments when the caller passed one) beside the `target` datasource; for `templates_render` / `templates_evaluate` the `template` id beside the `target`; `outcome` (`success` \| `error` + `code` \| `invalid_params` \| `internal_error` \| `permission_refused`); `elapsed_ms`; `correlation_id`. A resource read records `uri` instead of `tool`/`target`, and its `code` names the failure (`resource_not_found`, `forbidden`, `internal_error`) — a name, never the JSON-RPC number. Since 139 the table/template identifiers are what the entry-point checks learn from (`pipeline.validation.table_not_learned` §6.2.4, `pipeline.execution.template_unrendered` §6.2.3); rows written before 139 carry neither key, and those simply do not count.

**Which tools are mutating is a declared property of the catalog entry** (`McpToolCatalog.Entry.mutating`), never a name pattern — `McpToolCatalogBindingTest` fails if a catalogued tool lacks the declaration or a known writer (`pipelines_create`, `pipelines_update`, `pipelines_execute`, `pipelines_execute_node`, `templates_create`, and since 140 `pipelines_run_checks` — it writes the `pipeline_check_runs` rows) is flagged read. The failure direction is asymmetric: a read tool declared mutating is a harmless over-audit; a mutating tool declared read is the hole.

**Node runs are covered** (the point of 052): `pipelines_execute_node` runs real DML/DDL with no execution row, no SSE, no idempotency record — §6.2.20's ratification covers "no execution history", not "no trace". The `mcp.tool.write` row naming pipeline, node and version IS the trace that the write happened and by whom.

**Deliberately NOT recorded:** SQL text, row data, parameter values. Those are customer data; the event records THAT a write happened and by whom, never what it contained. Only identifier-shaped arguments are read from the request — the `parameters` map is never touched. The one exception with a rule of its own (107): an argument named `sql` (`sql_probe`, §6.2.34) is recorded as **`sql_sha256` + `sql_length`, never verbatim** — a probe statement is customer-authored text with the same transcript-hazard profile as a credential, and the hash is what an operator needs to pair an audit row with a reported statement without the log ever holding the text.

**Failure discipline:** emission happens after the tool returns and cannot change the tool's result or its error; an audit-sink failure is logged server-side (WARN + correlation id) and swallowed — the customer's call does not fail because bookkeeping did.

The event names are registered in [Enums §15](/docs/enums.md#15-authauditevent--auth-audit-log-events); the shared sink and its shape are [Auth §10](/docs/auth.md#10-audit-log).

---

## 15. The manual: how an agent learns this server

> **Status:** normative (095; rendered at boot since 242a). One home, four deliveries.

An agent connecting from OUTSIDE a checkout used to get the tool descriptions, the prompts and
eight lines of workspace context — and not one word about how to author. The skill it needed
existed only as a file in the repository. It is now served by the application itself.

**The source.** The document set rendered at boot (`DocRenderer`, `co.datapipelines.mcp.docs`):
the narrative resources packaged in the jar — `skill/core.md` (since 242b: the orientation
document — what the product is, how the manual is retrieved, the universal rules stated once
(workspace boundaries, key and role, humans register datasources, draft and release, parameter
safety, error recovery, naming), and the area index), the seven area guides — `pipelines`,
`executions`, `templates`, `scripts`, `datasources`, the lake guide, `endpoints`; the file
is the flat name plus `.md` — with concepts, workflow, prerequisites, common mistakes, and
each guide's own list of its references with "open when"; and the per-topic references the
judgment lives in (`skill/pipelines-learning.md`, `skill/pipelines-dag.md`,
`skill/pipelines-verification.md`, `skill/datasources-semantics.md`,
`skill/scripts-contracts.md`, …) — plus the generated documents (the per-area tools
references, the error codes, the calculator catalog). The core is named `core`; `skill` is its
one-release alias, and the previous delivery's names (`authoring-playbook`, `connecting`, …)
answer with their successors' bytes — since 242b the first two answer with the `pipelines` and
`datasources` guides. Configuration values the prose quotes (`${execution_timeout_seconds}`,
the timeout budgets) are typed placeholders substituted from the boot configuration, so the
manual a deployment serves is the manual it runs. There is exactly one home; everything below
is derived from it.

**Delivery 1 — the handshake (push).** `initialize`'s `instructions` (§5.1) is the operating
core distilled, RANKED for a client that cuts it (#241): the workspace scope; then the learning
path, which applies to creating AND updating — the core (`docs_get`, or the resource) beyond
one node, the authoring playbook beyond two, the task's reference from `docs_list`, and
plain-HTTP `GET /skill.md` / `GET /skill/{reference}.md` for a client with no MCP transport;
then the draft rule, the name grammar and `confirm_new_root`, "humans register datasources" with
introspection before SQL, and the three recoveries an agent gets wrong most often, one line each.
It lives in `modules/mcp-server/src/main/resources/mcp/server-instructions.txt` — a file, so the
diff is readable and the bytes are assertable.

**Its budget is what a client SHOWS, not what the server sends.** Claude Code truncates an MCP
server's instructions, and each tool's `description`, at **2,048 characters** unless the session
sets `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH` (read from the runtime, Claude Code 2.1.283, which
compares the JavaScript string length — UTF-16 units — and appends `… [truncated]`). The
handshake was 3,157 characters with its learning path from character 2,687, so every default
session read a briefing that stopped mid-sentence and never named the manual. The text is
therefore held to **1,843 characters and 1,843 UTF-8 bytes** (10 % headroom under the cap),
every phrase an agent must see sits inside the first 2,048 characters, the rules keep the order
above, and every tool description stays ≤ 2,048 characters — `McpClientCapTest`. A customer is
never asked to raise the client's limit. The served text, verbatim (`SkillDistributionTest`
pins this block to the file):

```text
This server is workspace-scoped: every tool and resource operates inside the workspace the API key is pinned to. Other workspaces' content is absent, not hidden (it resolves as not-found), and you see exactly the datasources granted to this workspace.

READ THE MANUAL BEFORE YOU AUTHOR — creating a pipeline or updating one, the same sequence. Beyond one node: docs_get {"name": "skill"} (or the resource datapipelines://docs/skill). Beyond two nodes: ALSO docs_get {"name": "authoring-playbook"}, then the task's reference from docs_list. Without MCP, the same documents are plain HTTP, unauthenticated: GET /skill.md, then GET /skill/<reference>.md.

Everything you author lands as a DRAFT — a create included — and is not live until a person releases it from the UI; no tool releases or promotes anything. Test it with pipelines_execute (no version runs your draft), then stop and say it awaits review.

NAMES ARE FOLDER PATHS: 2-10 lower-case segments separated by "/" (acme/mobility/daily_by_zone.sql), never renamed. List the roots first (pipelines_list and templates_list with {"prefix": ""}) and reuse one; a new root is refused until you ASK THE PERSON and pass confirm_new_root: true. Experiments go under test/.

HUMANS REGISTER DATASOURCES: no credential travels through an agent, so no tool creates or edits one — ask a person to add it in the UI. Introspect before writing SQL, never from recalled column names.

WHEN SOMETHING FAILS:
- pipeline.version.conflict: re-read with pipelines_get and rebase onto the current body_hash; a blind retry overwrites a human's edit.
- A validation error is YOUR bug: fix the document, never resend it unchanged.
- A failed execution's root cause is the LAST entry of error.exception.caused_by; report it with error.correlation_id.
```

Anything that does not change what an agent DOES on its first calls belongs in the skill, which
states in full every rule this text shortens; the one fact the shortening dropped that the skill
did not already carry — pipeline and template names are unique per workspace, datasource names
across the server — moved to its Core concepts.

**Delivery 2 — the resource (pull, MCP).** `datapipelines://docs/skill` (the core) and
`datapipelines://docs/skill/{name}` (any document of the set; §7.1, §7.2.4), `docs.read`,
listed by `resources/list` ahead of the entity kinds. Since 120 the same bytes also answer as
TOOLS — `docs_list` / `docs_get` (§6.2.40–41), from the same rendered `DocSet` — because
resources are a weak surface: several clients fetch them reluctantly or never, and every
client calls tools (R3). The tools carry the 24,000-character response budget and the
by-section form; the resources keep serving whole documents (owner ruling O3 keeps their URI
shapes, which carry no section segment), so the handshake points discovery at the tools.

**Delivery 3 — the URL (pull, HTTP).** `GET /skill.md` (the core) and `GET /skill/{name}.md`
(any document by its flat name), `text/markdown`, **unauthenticated** — it is the manual, it
holds no secret, and requiring a key would mean an agent cannot learn to use its key correctly
until after it has one. This is the delivery for Cursor, Codex CLI, Copilot and anything else
that speaks no MCP: one `curl` and the agent has the manual for the version this
deployment actually runs. An unknown name is a `404` in the §4.2 envelope with
`details.reason: "skill_reference_not_found"`; the one-release aliases of the previous
delivery's names (`skill`, `authoring-playbook`, `connecting`, …) answer with their
successors' bytes.

*The rendered `/docs` viewer does NOT list the manual, deliberately.* That index is the
operator-facing spec set, grouped by `DocsCatalog` and link-rewritten to GitHub for anything it
does not package; the manual is agent-facing and its document index would render as dead
links. The raw route is the one an agent needs, and it is the one that exists.

**Delivery 4 — the Claude Code plugin.** `.claude-plugin/marketplace.json` at the repository
root and `plugins/datapipelines/`: `/plugin marketplace add msabiransari/datapipelines` then
`/plugin install datapipelines@datapipelines`. Since 242a (record §6, ruling O1) the plugin
ships NO copy of the manual: its `skills/datapipelines/SKILL.md` is a **pointer** of under
twenty lines whose front matter names the product and whose body says to connect the server
and call `docs_get {"name": "skill"}` — nothing else is mirrored, and there is no parity
test, because a pointer cannot drift. The deployment URL and the API key are plugin
`userConfig` values substituted into the server entry — `${user_config.url}` /
`${user_config.api_key}` — because a plugin `.mcp.json` expands only those and the
`${CLAUDE_PLUGIN_*}` path variables, never arbitrary shell environment variables.

**Packaging (why it cannot break `main`).** The narrative resources live in
`modules/mcp-server/src/main/resources/skill/` and are packaged by the standard
`processResources` — in their own classpath root, `skill/`, never under `docs/`, which
`web`'s `DocsCatalog` scans and where an ungrouped file fails the application context at
init. `DocRenderer` (`co.datapipelines.mcp.docs`) is the single reader, so the tools, the
resources and the URL cannot answer differently.

**The generated documents.** The per-area tools references (`<area>-tools`), the error-code
reference (`core-error-codes`, every §13 code with its HTTP status and the server's message)
and the calculator catalog (`pipelines-calculators`) are rendered by `DocRenderer` at boot
from `McpToolCatalog` + the shipped tool definitions, `PipelineErrorCodes` + the §13
projection, and `CalculatorRegistry` — never typed (three hand-typed tool counts were stale
simultaneously when this section was first written; the handwritten sections may NAME tools,
they may not list them). `./gradlew :modules:mcp-server:docsExport` writes the same rendered
set to `build/skill-docs/` for `scripts/docs-audit.sh` (its checks A–C run over the exported
files) and for a human who wants to read what the server serves without booting it.

**The analytical rules (#116).** External authoring runs (#111) executed cleanly, reconciled
their own arithmetic, and still answered the wrong question: a sampled count added unweighted
to a full count to rank, on the claim that scaling one summand cannot reorder a ranking; a
difference ranked after its parts were rounded; a volume floor read as support for every
subgroup; an absent sampled group reported as zero; a verification that re-ran the authored
formula and therefore agreed with it. The rules are one decision sequence, not a list of
incidents: `pipelines-numbers` holds the measurement contract (population, inclusion policy,
unit and sampling weight stated, with why the inputs are comparable, before anything is
combined), normalising every sampled count before a total, share or ranking, reusing a helper
by its formula and precision, precision carried to the output boundary with a stated
tie-break and a verified cutoff, and support and missingness shown beside each estimate;
`pipelines-verification` holds verification derived again from the question and its source
weights — every requested dimension, the cutoff and its neighbours, output reconciliation kept
distinct from source-only checks and proxies — the door exercised with an alternate and an
uncovered input, and approved definitions recorded through shared semantics rather than a
client's memory. Timeouts (effective limits first; partitioning only with exact coverage and
additive recombination) are `pipelines-dag`'s, and the tempdb probe's `incomplete` outcome
(#119) is explained there and in `pipelines-engine-quirks`. The guide's steps 1½ and 5 name
the weighting rule and the derived-verification rule, so an author meets both without opening
a reference, and `pipelines-numbers` carries one worked case — invented sites, invented
numbers — in which weighting the sampled feed changes the winner. Guidance lowers the rate of
this failure; it cannot demonstrate adherence. The fresh-client run #111 tracks is that
evidence.

**The guards**, each able to go red: the core ≤ 400 lines with prose no line folding past the
readable column, and within its 8,000-character orientation bound (242b); `instructions`
within 1,843 characters and bytes, every phrase an agent must see inside the first 2,048
characters, its rules in their ranked order and equal to the Delivery 1 block above; every
tool description ≤ 2,048 characters; every tool exactly once in its area's tools reference
with its shipped description and catalogued permission; every §13 code exactly once in the
rendered error-code reference; every node type in the node-types narrative and every
calculator kind in the catalog; no reserved area name (`scheduling`, `reporting`,
`dashboards`) appearing in any served document until the lane that ships it adds the area
(242b); golden tests pinning each narrative document's rendered text (a changed placeholder
turns them red); the weighting rule and the derived-verification rule present in the guide
and in their references, and the worked case's table adding up with a different winner once
weighted (#116); no demo content anywhere in the rendered set; documents and sections within
the 24,000-character response budget; the two resource URIs read, list and 404 correctly;
`GET /skill.md` 200 `text/markdown` anonymous and `GET /skill/nope.md` 404 in the envelope;
the audit green over the exported set.

---

## Appendix A: Change Log

| Date | Version | Author | Change |
|---|---|---|---|
| 2026-10-25 | v1.77 | 706 (#706) | **§4.1/§6 preamble/§9.3: the live prose still taught the retired PK4 cap, the membership-freshness rule (C2) and `auth.key_issuer_role_lost` as the MCP refusal, contradicting §4.1's own keys-v2 bullets and §4.2 — rewritten to the key's chosen role and `auth.role_required`; recovery = a new key under the subset rule. §13's checklist includes workspace-admin keys and preserves MCP creator liveness (A20), and §9.3 names identity or creator deactivation; the §4.1 TTL sentence says the same (revocation and deactivation reach the key; a member role change does not). History rows v1.47/v1.50 unchanged. No tool, route, permission or code change.** |
| 2026-10-22 | v1.76 | 8b (#8) | The type description and §6.2.43 describe JavaScript availability, statement bodies and console results. |
| 2026-10-21 | v1.75 | #640 the script-node rename (script-node-rename lane, V50, owner ruling 2026-10-20) | **The template-type and node prose say script**: the script contract / invariants / tests descriptions, the `scripts` docs area and `skill/scripts-contracts`, the `templates_*` refusal reason `type_not_script`, the key `datapipelines.script.evaluate-timeout-seconds`; the visualization write tools' block property stays `transform` (restored in this lane's third round; `ArtifactWriteSchemaReaderParityTest` pins schema = reader keys). A rename only: no behaviour, route, permission or key change beyond the names, and no alias (the ruling). The visualization block's `transform` key, `transform.template` / `transform.inputs` and the CSS/SVG sense keep their word. |
| 2026-10-11 | v1.74 | 504b (#504b) one shared search-needle rule | **§6.2.2 `pipelines_list` and §6.2.6 `templates_list` say what `q` matches and its bound**: the `inputSchema`'s `q` description now states the case-insensitive substring match, the columns actually searched (name, display name and description on pipelines; name and display name on templates — #424's understatements corrected), that a present `prefix` wins, and the shared 200-after-trim bound (#490, [pipeline-contract §13.21](/docs/pipeline-contract.md) v1.58). A longer `q` on ANY list tool is refused JSON-RPC `-32602 invalid params` (§9.1) naming the limit, the length and `request.query_too_long`, before any read and without echoing the term — the mapping moved from `parameter_sets_list`'s own catch to the dispatcher, so all three tools answer identically. REST answers the same term with `400` ([REST §21.2](/docs/rest-api.md#212-routes) v2.95). No tool, argument name, count or permission changed. |
| 2026-10-09 | v1.73 | 490 (#490) the search needle's bound | **§6.2.44 `parameter_sets_list`**: `q` is bounded at 200 characters after the trim — the `inputSchema`'s `q` description says so, and a longer `q` is refused JSON-RPC `-32602 invalid params` (§9.1, the route a non-string `q` already takes) naming the limit, the length and `parameter.validation.query_too_long`, before any read and without echoing the term. The bound is `ParameterSetService`'s, shared with REST (`400`, [REST §21.2](/docs/rest-api.md#212-routes) v2.91); no tool, argument, count or permission changed. |
| 2026-10-08 | v1.72 | 419 (#419) the `parameter_sets_list` `q` search | **§6.2.44 `parameter_sets_list` gains the optional `q`**: with no `prefix` and a non-blank `q` it searches name, display name and description case-insensitively through the same lensed `ParameterSetService.search` the first-party pages use, answering `{q, parameter_sets, returned, total}` — no `folders` (a search is not a level), and `total` is the full match count so an agent sees a truncated `limit`. A present `prefix` (including `""`) still browses and `q` is ignored — the branch switch is `has("prefix")`, not `string`, so an absent prefix with a blank/absent `q` keeps today's root browse byte-identically. No tool, argument name, count or permission changed; the `inputSchema` gains one property ([REST §21.2](/docs/rest-api.md#212-routes) v2.89). |
| 2026-10-05 | v1.71 | #459 draft dashboard dependencies | Dashboard list/get/create descriptions and source schemas name exact live DRAFT or RELEASED versions during authoring and explicit draft preview. Publication and imports retain released dependencies. No tool, argument, permission or role change. |
| 2026-10-03 | v1.70 | 405 (#405) a datasource's own timeout tightens the probe | **§6.2.34 `sql_probe`: a datasource's own `query_timeout_seconds` tightens the dialect ceiling, never loosens it** — the bound is `min(ceiling, query_timeout_seconds)`, so a probe on a datasource set below the ceiling runs no longer than that datasource's nodes; v1.69's "not consulted" is replaced. The payload's `timeout_seconds` reports the tightened value. No tool, argument, `inputSchema`, count or permission changed ([Datasources §7D](/docs/datasources.md#7d-the-sql-probe) v2.52). |
| 2026-10-02 | v1.69 | 167 (#167) the probe's timeout ceiling follows the node's | **§6.2.34 `sql_probe`: `timeout_seconds` is clamped to the instance's node query timeout for the datasource's dialect** (`node-query-timeout-seconds-by-dialect.<dialect>` when set, else `node-query-timeout-seconds` — Configuration §3.2), no longer to a static 30 s: an acceptance run's verification probe over the lake table timed out at 30 s while the same scan completed as a node. The `inputSchema` drops `"maximum": 30` for `timeout_seconds` (the ceiling is configuration, so a static schema cannot state it; `minimum` 1 and `default` 10 unchanged, drift-pinned as before). The success payload gains **`timeout_seconds`** (the timeout the statement ran under) and a timeout's `details` carry it too. A request above the ceiling is still clamped, never refused. No tool, argument name, count or permission changed ([Datasources §7D](/docs/datasources.md#7d-the-sql-probe)). |
| 2026-10-02 | v1.68 | 235 (#235) the drift test pins every tool's description | **Additive, no surface change.** 19 blocks gain their top-level `description` — every `parameter_sets_*` (6), `visualizations_*` (5), `dashboards_*` (6) and `visualizations_test_*` (2) block, the verbatim shipped string; the drift test now pins descriptions (it compared `inputSchema` only). Seven stale descriptions are refreshed to the shipped string (the code was right, the blocks predate #194/#274/#320 and the visibility and TRANSFORM-node refusals): `endpoints_delete`, `endpoints_list`, `executions_get`, `executions_get_result`, `pipelines_execute_node`, `templates_purge_draft`, `templates_used_by`. **§6.2.5 `pipelines_update`** (input documented in prose) gains a description-only block so its string is pinned too. No tool added or removed: **61 stays 61**, permissions and every `inputSchema` unchanged. |
| 2026-10-01 | v1.67 | L4b (#353) the visualization test tools | **§6.1: 59 → 61 tools** — `visualizations_test_start` and `visualizations_test_submit` on `visualization.update` (both mutating): the start answers `preview_url` (the preview capability inside it, shown once), the cases and the deadline; the submit takes per-case verdicts, notes and the closed environment set through the one reader REST uses and answers the status, the mechanical report and — GREEN only — the single-use screenshot upload (url, header, token), which the agent POSTs over HTTP: an `mcp` key reaches no REST route. New §6.2.61–62; §5.1's static count 59 → 61. The manual's `dashboards` guide gains the test loop. |
| 2026-09-29 | v1.66 | L2 (#10) the dashboard runtime — renumbered at merge after 320's v1.64 and 264's v1.65 | §6.2.56 `dashboards_get`: `last_refresh` is live — the CALLER's own latest refresh of the dashboard, `{refresh_id, dashboard_version, status, started_at, finished_at}`, or `null` when they have none (it was always `null` until the runtime shipped). Read through `DashboardRefreshHistory`, which asks by the caller's user id and no other: nobody else's refresh appears, whatever their role, and no selection is returned. No tool refreshes a dashboard — `dashboard.execute` has no MCP placement until the `dashboard` key kind (L5). The served manual's dashboards page gains one sentence. |
| 2026-09-29 | v1.65 | 265 (#265) the probe's parameters judged by the shared coercion — renumbered at merge after 320's v1.64 | **§6.2.34 `sql_probe`: the `parameters` values are judged by the shared strict coercion** (typesystem's `ParameterCoercion` through `ParameterLift`) instead of the datasources-local copy that still trimmed — the tool's `parameters` description states the forms exactly: plain decimal text for the BIG numerics, exact ISO temporals (TIMESTAMP with an explicit offset or `Z`), padded standard base64, nothing trimmed, booleans exactly `true`/`false`, the 1024-digit cap before any parse. A padded or truthy-looking value that bound before is now a `-32602` argument fault naming the parameter, never the value. No tool, argument, count or permission changed ([Datasources §7D](/docs/datasources.md#7d-the-sql-probe)). |
| 2026-09-30 | v1.64 | 320 (#320) dependency guards | §6.2.21 `templates_used_by` gains `visualization_references` (a visualization's `transform.template` pin, working-version scan, under the visualization lens); §6.2.32 `templates_purge_draft`'s guard gains its third arm — a visualization version that pins ANY version of the draft refuses it (`template.in_use`, `details.referencing_visualizations`, the echo narrowed by the caller's view with `pins_hidden`, the #300 shape). No tool added, no permission added. |
| 2026-09-29 | v1.63 | L1b (#10) the visualization and dashboard tools | **§6.1: 48 → 59 tools — five `visualizations_*` and six `dashboards_*`** (the implementation spec §7; addressed by id per P24; no tool releases or executes anything): `visualizations_list`/`get` and `dashboards_list`/`get` (reads, the promoter lens — dashboards whose every source pipeline the pipeline lens admits, and the visualizations they pin; `used_by` on the visualization rows; `dashboards_get` carries each pin's lensed status and `last_refresh: null` until the runtime ships), `_create`/`_update` (writes through the same strict reader, bounds and save-time validation REST uses; the 094 new-root confirmation), `_purge_draft` (versioning §5.4, hash-guarded; a pinned visualization draft refused `visualization.version.pinned`), and `dashboards_validate` — the §3.2 rules against current state, no write, on `dashboard.update` (owner ruling 2026-09-29; the spec said `dashboard.read` — the validator reads pin statuses unlensed, O5). New §6.2.50–6.2.60; §5.1's static count 48 → 59. The manual gains the `dashboards` area (`skill/dashboards.md`, the `visualizations`/`dashboards` prefixes mapped; `dashboards` leaves the reserved prefixes). `visualizations_test_start` / `_submit` land with the test sessions (L4). |
| 2026-09-29 | v1.62 | 310 (#310) audit-log retention | §6.2.4's learn-before-you-write paragraph: check A's learning lasts as long as the audit row it reads — once per `datapipelines.audit.retention-days` (default a year), not "the key's lifetime", now that the audit log is retained (auth §10.3). No tool, input or refusal changes; the status line (left at v1.60 by v1.61) catches up |
| 2026-09-28 | v1.61 | the 300 merge's security pass (#300) | **§6.2.32: `templates_purge_draft`'s guard reads the whole workspace and its refusal is view-narrowed without a cardinality.** Both scans (pipelines, parameter sets) run unlensed — a pin the caller's view hides still refuses; the refusal names only the admitted pins and, when others exist outside the view, `details.pins_hidden: true` (the lane's count was retired at merge: a count of hidden objects is an existence oracle). No route, permission or code added. |
| 2026-09-28 | v1.60 | 298 (#291) the transport's error bodies — renumbered at merge after 116's v1.59 | **§3**: a body the transport cannot read (malformed, or nested past §13.21's 100) answers `400` with the SDK's JSON-RPC error and nothing else — the transport's mapper drops `stackTrace`, `cause`, `suppressed` and `localizedMessage` from every throwable it writes. Measured before: the 400's body was the SDK's serialized `McpError`, stack trace and all. No tool, schema or permission changed. |
| 2026-09-28 | v1.59 | 116 (#116) the authoring guidance | **§15: the analytical rules — no surface change.** The #111 runs executed and reconciled their own arithmetic while answering the wrong question (an unweighted sampled count added to a full count to rank; a verification that re-ran the authored formula). Lane 147's rules, moved by 242b into `pipelines-numbers` and `pipelines-verification`, were audited against #116 clause by clause and kept; the gaps closed: rule 2 now requires the description to say WHY combined inputs are comparable; `pipelines-numbers` gains one worked case (invented sites and numbers) in which weighting the sampled feed changes the winner and a recomputation of the authored formula agrees with the wrong answer; the pipelines guide's steps 1½ and 5 and its common-mistakes line name the weighting rule and the derived-verification rule; `pipelines-engine-quirks` § 6.3 names `pipelines_execute` as the way to finish an `incomplete` tempdb probe (it said "the node run", which read as `pipelines_execute_node`, which refuses tempdb — #119's review correction); the guide's step 3 says a render shows the SQL, not that H2 accepts it, and sends a tempdb template's rendered statement to `sql_probe` for its `validation_status` (the step read as if `templates_render` returned that field; only `sql_probe` does). New guard `AnalyticalGuidanceRulesTest` over the rendered set: the two rules in the guide and in their references, and the worked case's table adding up with a different winner once weighted. The core is untouched. |
| 2026-09-28 | v1.58 | the 194d merge's security pass | **§6.2.45 `parameter_sets_get`: `draft` is null under a narrowing lens** — the promoter never sees a draft pointer (178, `pipelines_get`'s rule); the tool had re-read the draft unlensed after the lensed working read. `parameter_sets_create`'s 094 new-root check now lists the WHOLE root level (it listed one root, so a name under any other existing root was refused as new). |
| 2026-09-28 | v1.57 | 194d (#194) the parameter engine's tools — renumbered at merge after 279's v1.55 and 286's v1.56 | **§6.1: 42 → 48 tools — the six `parameter_sets_*`** (the record's §9.1; addressed by id per P24, no release/switch tool — none exists for pipelines or templates either): `parameter_sets_list`/`get` (reads; the promoter lens), `_create`/`_update` (writes; full save-time validation; the 094 new-root confirmation; the §13a.2 ask-before-a-MULTI-without-a-hint rule in the description), `_evaluate` (the §5 runtime; a VIEWER row per C24; the 139 gate's twin — a DRAFT evaluate whose pinned DRAFT template postdates the key's last `templates_render` of it refuses `parameter.evaluate.template_unrendered`, read from the same audit table), `_purge_draft` (versioning §5.4, hash-guarded, the write audit). New §6.2.44–6.2.49. The manual gains the `parameters` area; the matrix places the six on the nine `parameter_set.*` rows. |
| 2026-09-28 | v1.56 | 286 (#275) — numbered after 279's v1.55 at merge (landed before 194d) | **§7.3's role filtering**: the executions resource listing reads what `executions_list` reads — `findVisible` (own runs plus every `triggered_via = SCHEDULE` run of the workspace) under `execution.read`, `findAll` under `execution.read_all`; until #275 it read own-only `findByUser` for every key, admins included, so a scheduled run was on the tool's list and missing from the resources. No tool or URI changed. |
| 2026-09-28 | v1.55 | 279 (#279) the request-body cap | **§3 names the transport's cap**: the platform's filter (pipeline-contract §13.21, [Configuration §3.31](/docs/configuration.md#331-web-request-limits)) sits ahead of the transport servlet, refusing a body over `datapipelines.web.max-request-bytes` with the same `413 request.body_too_large` envelope REST answers; the transport's JSON mapper carries the stated Jackson `StreamReadConstraints`. No tool, no schema, no permission change. |
| 2026-09-27 | v1.54 | 274 (#274) legacy endpoint rows, retired never fatal | **§6.2.24 `endpoints_list` answers legacy rows flagged** — after the valid rows, a stored path saved before the current grammar carries `"legacy": true`, its `"reason"` (the grammar's own refusal, bounded), `"enabled"` and `path`/`pipeline`/`url`; no `path_variables` (there is no parse). **§6.2.25 `endpoints_get`** answers a legacy path not-found (the single read is valid-shape only). **§6.2.26 `endpoints_delete` is the fix** — the descriptions state it; removing the row is the one verb a legacy row supports. No tool added or removed: **42 stays 42**, permissions unchanged (`endpoint.read`, `endpoint.unpublish` — the same rows for legacy rows as for valid ones). The manual's `endpoints` guide carries the one sentence. |
| 2026-09-27 | v1.53 | 250 (#250, #9 R3) scheduled runs on every surface | **§6.2.13 `executions_list` reads `findVisible`** — own runs plus every `triggered_via = SCHEDULE` run of the workspace (rest-api §10.1's R3), the visibility decided in SQL before the page is cut; `executions_get` and `executions_get_result` answer for a scheduled run the same way (the shared `visibleTo` gains the schedule branch). Descriptions state it; **42 stays 42**, permissions unchanged (`execution.read`, the matrix row untouched). The manual's `executions` guide says the same. |
| 2026-09-26 | v1.52 | 242b (#242) the narrative by area | **§15 rewritten (the source, the guards)** — the narrative split by functional area: `core.md` is the orientation document (product, retrieval, the universal rules stated once, the area index — 6,353 chars, inside the new 8,000-character bound, whole-document again); seven area guides (`pipelines`, `executions`, `templates`, `transforms`, `datasources`, `lake`, `endpoints` — concepts, workflow, prerequisites, common mistakes, their references with "open when"; O5's `executions` area got its guide); the playbook's judgment split into per-topic references (`pipelines-learning`, `pipelines-dag`, `pipelines-numbers`, `pipelines-verification`, `pipelines-engine-quirks`, `pipelines-do-dont`, `templates-calculators`, `transforms-contracts`, `transforms-evaluation`, `datasources-semantics`), all within the 24,000-character budget. `connecting`'s content became the datasources guide's workflow; `authoring-playbook` and `connecting` now answer with the `pipelines` and `datasources` guides (the record §4 alias contract; one release). New guards: no reserved area name in any served document; the core's 8,000-character pin. No tool surface change: **42 stays 42**, permissions unchanged. |
| 2026-09-25 | v1.51 | 242a (#242) the manual rendered at boot | **§15 rewritten** — the manual is no longer a repo file but a document set rendered at boot (`DocRenderer`) from narrative resources in the jar (`modules/mcp-server/src/main/resources/skill/`), the boot configuration (typed `${placeholders}` — the timeout budgets the prose quotes) and the catalogs. **§6.2.40 `docs_list`**: optional `area` filter; without it the core plus one entry per area with the area's other documents nested; entries carry `area`, `layer`, `chars`, `over_budget`, `sections` and nested `references`. **§6.2.41 `docs_get`**: optional `section` (by-section read), whole-document answers within the **24,000-character** response budget (`details.reason: document_over_budget` otherwise, section list attached), `next` continuations for split sections, and the one-release **aliases** (`skill`, `authoring-playbook`, `connecting`, `dp-lake`, `error-codes`, `naming`, `node-types`, `pipeline-schema`, `tools`) answering with the successors' bytes. Per-area tools references (`<area>-tools`), `core-error-codes` (generated from the §13 projection) and `pipelines-calculators` join the set as generated documents; `.agents/skills/datapipelines/` and its build tasks (`skillToolsDoc`, `pluginSkillCopy`, `skillArtifacts`) are gone; `docsExport` renders the set to `build/skill-docs/` for the audit. `datapipelines://docs/skill/{name}` and `GET /skill/{name}.md` serve the set by flat name; the plugin is a pointer (§15 Delivery 4). No tool added or removed: **42 stays 42**, permissions unchanged (`docs.read`). |
| 2026-09-25 | v1.50 | keys v2 (#233) | **§1/§2/§3/§4/§11: the MCP key is a robot member** — created on the Keys page with a role chosen under the subset rule (A13–A15), acting as its own identity with that member role; the login mint, the top-bar chip and the PK4 cap are gone, and a role refusal is `auth.role_required` (`details.held` = the key's role). The `user` kind is renamed `mcp` (A19). §6.2 descriptions that still described pre-v2 access rewritten: `endpoints_create` (an unbound endpoint is served to no one — never "user keys with the execute scope"), `executions_list`/`executions_get`/`executions_get_result` (the key acts as its role; `execution.read_all` lifts own-only), `semantics_retire` (`datasource.manage` decides the cross-workspace retire — not "a workspace admin"). `references/tools.md` regenerated from the shipped surface. |
| 2026-09-25 | v1.49 | 241 (#241) the handshake under the client cap | No tool, schema or permission change (42 stays 42). **§5.1 `instructions` rewritten for what a client SHOWS**: Claude Code cuts server instructions and each tool description at 2,048 characters by default (`CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`, read from the 2.1.283 runtime); the handshake was 3,157 characters with the learning path from character 2,687, so a default session never saw it. Now 1,783 characters / 1,791 bytes, ranked: workspace scope, the learning path (`docs_get` skill → `authoring-playbook` → `docs_list`, the HTTP twin), the draft rule, names + `confirm_new_root`, humans register datasources, three recoveries. The stale "datasources are bound or global" clause (global is gone since D-R7) is corrected to "exactly the datasources granted to this workspace". **§15 Delivery 1** carries the served text verbatim, pinned to the file by `SkillDistributionTest`; the 4096-byte cap is replaced by `McpClientCapTest` (1,843 characters and bytes, the required phrases inside the first 2,048 characters, the ranked order, every tool description ≤ 2,048). SKILL.md's Core concepts gains the one dropped fact it lacked (name uniqueness per workspace, datasource names per server). |
| 2026-09-25 | v1.48 | 7e (#7) the semantic link | No tool added (42 stays 42), no permission changed. **`templates_create` / `templates_update` accept `implements`** (§6.2.8, §6.2.36): the learned facts a transform version cites, outside `body_hash`; on update an omitted `implements` is inherited (owner ruling 2026-09-25), `[]` clears, and an implements-only write on a released version opens no draft; refusals `template.implements_unresolved` (new, §13.9) and `template.blocks_not_allowed` on `sql`/`html`. **`templates_list` gains `implements=<fact id>`** (§6.2.6) and every row carries `implements` + `needs_review`; **`templates_get`** (§6.2.7) carries `implements`, `needs_review` (a cited fact retired, computed on read) and `retired_facts` (`{fact_id, retired_reason, superseded_by}`). **`semantics_list` rows** (§6.2.38) and the **`definitions`** on `datasources_list`/`datasources_get` (§6.2.10/§6.2.18a) carry `implemented_by` on every WORKSPACE fact, under the key's template lens. `tools.md` regenerated. |
| 2026-09-24 | v1.47 | 215b (#215) key identities, key roles | **Scopes removed** ([Auth §7.5](/docs/auth.md#75-key-roles)): every §6.2 tool's `**Scope:**` row is now its **`**Permission:**`** — the catalog permission the tool declares on `McpToolCatalog.Entry.permission` (moved there from the auth matrix); the MCP key acts as its member's current role capped at author (PK4) and is never a super admin (B1); a role refusal is `auth.key_issuer_role_lost`, an undeclared tool `auth.permission.undeclared`, the two ownership rules `auth.role_required` with `details.reason`. §4.1: **the MCP key connects an MCP client to `/mcp` and nothing else** (B2). Resources are filtered by each family's read permission (a promoter's key lists no executions). §14's refusal outcome is `permission_refused`. Drift fixed on the way: three `datasources_create` paragraphs (removed in 094) had been stranded under §6.2.26 `endpoints_delete` — gone. |
| 2026-09-24 | v1.46 clarification | #219 | Correct §6.2.17 and §6.2.33 partition/pruning guidance and matching tool descriptions. Null means no registered key, not one file or a mandatory full scan. No tool, schema, permission or execution behavior changes. |
| 2026-09-23 | v1.46 | 7b (#7) | **`templates_evaluate` lands (41 → 42 tools, §6.2.43)** — the transform evaluator: a caller-supplied input object through the bounded evaluation pool with the contract's input check, the type gate and the invariants; the working-version resolution is `templates_render`'s, and `templates_render` now refuses a transform type with `template.render_not_applicable` pointing at it (§6.2.9). The `type`/`engine` enums in the create/update/list schemas are derived from the enums and admit `jsonata`/`javascript`/`none` (§6.2.6, §6.2.8, §6.2.36). §14's identifier list: the evaluate row carries `template` like the render row. Every count (§3's capability note, §6.1, §8, the two ScopeMatrix rows, tools.md) moved in the same commit. |
| 2026-09-21 | v1.45 | 180 (#180) | No tool, no schema, no permission change. §4.2 and §9.3: a deactivated OWNER is `401 auth.principal_deactivated` (new §13.7 code; was folded into `auth.api_key.invalid`), a deactivated PIN `404 auth.key_workspace_inactive`; both refused at validation, so `tools/list` is refused too ([Auth §11A.3](/docs/auth.md#11a3-deactivation)). |
| 2026-09-21 | v1.44 | 179 (#179) | No tool, no schema, no permission change on the MCP surface. The KEY an agent presents changed how it is born: §1/§2/§3 (authentication model, principle 3, the key bullet list) now say the user's key is minted at sign-in (D16) and copied from the top bar, one per workspace, its reach the owner's role re-read per request; the "Agent / API key" form label is gone with on-demand minting. §4.2's troubleshooting line points at the role, not a re-mint. |
| 2026-09-19 | v1.43 | 172 (#172) | §6.2.23 `endpoints_create` description and `path` schema text: published endpoints serve at `/api/<category>/<version>/<path…>` (R-EP5) — the category is the caller's namespace with `v[0-9]+` and `api` reserved (`endpoint.path_reserved`), the version free-form, one leading `/api` prefix normalised away; `endpoints_*` responses' `url` carries the full served URL. No tool added, removed or renamed; no scope change. |
| 2026-09-18 | v1.42 | 171 (#171) | No new tools, no schema change (`inputSchema` unaffected). **§6.2.23 `endpoints_create`** description text updated: a `DML`/`DDL` node whose `source` is `tempdb` is now side-effect-free and publishable — previously every `DML`/`DDL` node was refused regardless of source. Same rule, same code (`endpoint.pipeline_not_readonly`), same service both REST and this tool call ([REST API §19.2](/docs/rest-api.md#192-what-may-be-published)). |
| 2026-09-16 | v1.41 | 147 incomplete tempdb validation (#119) | No new tools, no argument change. **§6.2.34 `sql_probe`, `name: "tempdb"`**: a missing staged table is an INCOMPLETE validation, not a pass — the payload gains **`validation_status`** (`"incomplete"` \| `"executed"`) and `parsed` becomes `null` on the incomplete branch (present, not omitted; `true` only when the statement executed); the note says what was NOT checked and names the two ways to finish (a self-contained `VALUES` restatement, or a run with the real staged inputs). Measured on H2 2.3.232: a missing table stops preparation before a later syntax error, so the pre-#119 "parsed, every self-defined name resolved" claim was false (an acceptance run matched a passing probe's SQL hash to its failing execution). Tool and `name` descriptions updated (drift-pinned); guards `SqlProbeH2Test` (real engine, with the tables-present counterexample) and `SqlProbeTempdbWireTest` (real dispatcher, real JSON). Errors, binds, limits, cleanup, scope and the hash-only audit unchanged. |
| 2026-09-15 | v1.39 | 139 the entry-point checks | No new tools, one new input property. **§6.2.4/§6.2.5 `pipelines_create`/`pipelines_update`**: a body whose template names a table the key never `datasources_get_columns`'d is refused `pipeline.validation.table_not_learned` (§12.11; details.tables + the clearing calls); a raw-date door (two DATE parameters, no INTEGER period parameter, no window calculator) is refused `pipeline.validation.door_unacknowledged` unless the call carries the new **`door_acknowledged`** boolean — the `confirm_new_root` shape (094). **§6.2.3 `pipelines_execute` / §6.2.20 `pipelines_execute_node`**: a DRAFT version whose pinned DRAFT template was written after this key's last successful `templates_render` is refused `pipeline.execution.template_unrendered` (§13.3; details.templates). **§14**: the schema tools' rows gain `table` (+ `namespace`), `templates_render` rows gain `template` — the identifiers the checks learn from; pre-139 rows carry neither and do not count. All three checks are MCP-only (they read the caller's own audit rows); REST and the UI unaffected. |
| 2026-09-15 | v1.40 | 140 release checks over MCP | Tool surface 40 → **41**: new §6.2.42 `pipelines_run_checks` (scope `execute`, **mutating** — it writes one `pipeline_check_runs` row per check, so the `mcp.tool.write` audit's business) — the server runs a version's §3.3 `checks[]` NOW through the same `PipelineCheckRunner` REST `POST …/checks/run`, the UI and the release gate ride, persisting the rows before returning; `observed` exists only because the server's own run produced it, and there is deliberately NO `pipelines_record_check` tool — no tool records an observed value from a caller. `verdict` is `pass` \| `fail` \| `error` (error = no verdict could be formed: datasource unresolvable/unreachable, statement refused or an uncomparable shape, parameters did not bind — the truth recorded, never silently a fail). §6.2.4 `pipelines_create` / §6.2.5 `pipelines_update` gain the optional `checks` array property — passed through to the §3 body verbatim, validation stays server-side (§12.11). §6.1 lists it after `docs_get`; §5.1's static-surface count, §8's admission-rule count, §14's known-writer list and auth.md §7.6's MCP table (the `execute` row) moved in the same commit; the numbering appends 6.2.42 after 6.2.41 like 120/117 appended theirs. |
| 2026-09-14 | v1.38 | 136 §D the three T289 follow-ups | No new tools, no new fields. **§6.2.36 `templates_update`**: `type` no longer defaults to `sql` — absent, the working version's is inherited (an html template updated without `type` was refused `type_immutable`, T289b); schema `default` dropped, description says so. **§6.2.18 `datasources_get_columns` / §6.2.33 `datasources_get_table_stats` descriptions**: a LAKE datasource answers `datasource.lake_table_not_found` for a table its registry does not carry (T289c). REST `PUT /templates` inherits `dialect`/`type` the same way (T289a, rest-api.md v2.12). |
| 2026-09-14 | v1.37 | 136 §A/§B definitions where the agent looks; refs optional on a rule | No new tools. **§6.2.10 `datasources_list` / §6.2.11 `datasources_get`**: each per-datasource entry gains **`definitions`** — every WORKSPACE-scope fact (`definition`, `exclusion`, `preference`) visible to the reader on that datasource, with refs or with none, §7A.5 shape, newest last, `[]` when none, from the same one enrichment read as `facts` (which keeps its datasource-wide meaning); the acceptance run's agent met the workspace's rule buried on a column and re-chose it (T287). Descriptions updated (drift-pinned). **§6.2.37 `semantics_record`**: `refs` is OPTIONAL — a WORKSPACE rule that spans datasources carries none (T278; V26 relaxes the CHECK; `minItems` dropped, `refs` out of `required`); a DATASOURCE kind still needs one (`semantics.fact_invalid`). |
| 2026-09-14 | v1.36 | 135 §C templates_update inherits the dialect | No new tools. §6.2.36 `templates_update`: `dialect` is optional — omitted, the working version's is inherited before validation; present and different from the established one, refused `template.validation.dialect_invalid` naming both in `details` (T276: three acceptance-run updates in one day were refused for omitting the field the draft already had, and resent with that same value). The property's description and the tool description say so; `required` unchanged (it never listed `dialect` — the refusal was the validator's). REST §8.4 unchanged. |
| 2026-09-14 | v1.35 | 134 MCP save sees workspace datasources | No new tools, no schema changes — a documented rule made true. §4.1: the principal travels with the call (transport context), never on the thread — the SDK runs tool handlers on its `boundedElastic` scheduler where Spring Security's thread-local is empty, so anything reachable from a tool takes its workspace as an argument. The save-time datasource port read the thread-local and every `pipelines_create`/`pipelines_update` over MCP resolved only owner-less datasources (`unknown_datasource` for a workspace-owned one; `201` over REST for the same body). Fixed at the port (`DatasourceRegistry.describe(name, workspaceId)`), the executor's existing shape; guard `McpSaveWorkspaceDatasourceE2eTest`. |
| 2026-09-13 | v1.34 | 126 facts where the agent looks | No new tools, no inputSchema changes — additive response fields only. §6.2.10 `datasources_list`: each entry gains `facts`, the SAME datasource-wide learned-facts array §6.2.11 serves (`[]` when none, never an absent key) — the listing is the call every audited agent made first, so the learn-first facts ride it instead of waiting for a `datasources_get` that was being skipped; `datasources_get` remains the single-datasource refresh after `semantics_record`. §6.2.17 `datasources_get_tables`: for a LAKE datasource each table entry gains `partition_column` — the registered column's name, or `null` when none is registered — the same registry value §6.2.33's stats payload reports; non-lake dialects carry no such key. Descriptions for both tools updated to match (drift-pinned text); §6.2.18a gains the listing as a facts carrier. |
| 2026-09-13 | v1.33 | 121 calculator multi-output | No new tools. §6.2.23/24 (`calculators_list`/`calculators_get`): a kind's entry gains the multi-output shape (pipeline-contract §4.10, 121) — a single-output kind keeps `"output": "DATE"` (byte-identical wire, no `outputs` key); a multi-output kind carries `"output": null` and `outputs: [{name, type, description}]`, the named set a node maps through `context_keys` (every name mapped, or the save is refused). `pipelines_execute`'s derived execute inputs now list every key a multi-output node writes, and its refusal set gains `pipeline.execution.calculator_keys_partial` (pipeline-contract §13.3) — a proper subset of one node's keys is refused before anything runs. `executions_get`'s per-node stats gain `context_values` on multi-output nodes (single nodes byte-identical, `provided_by` unchanged). |
| 2026-09-12 | v1.32 | 120 docs as tools + catalog phrases | Tool surface 38 → **40**: new §6.2.40 `docs_list` and §6.2.41 `docs_get` (both scope `read`, non-mutating) — the skill's documents as TOOLS (ruling R3), serving exactly the `SkillDocs` bytes the §7.2.4 resources serve, because several MCP clients fetch resources reluctantly or never while every client calls tools. New code `mcp.doc_not_found` (pipeline-contract §13.16) for an unknown `docs_get` name — the resource read's not-found is the protocol's RESOURCE_NOT_FOUND, which a §9.2 content envelope cannot carry. §6.2.23/24 (`calculators_list`/`calculators_get`): every kind's entry gains **`phrases`** — the everyday phrases the kind answers (ruling R2); the descriptions say to match the question's words against them before picking a kind (ruling R1: the skill teaches the lookup, never the interpretation). **§14 gains `mcp.resource.read`** — every `resources/read` audited at `McpResourceReader.read` (uri, outcome, code, elapsed_ms, correlation id, key id + owner); until now a resource read left no trace. auth.md §7.6's MCP table, the §5.1 static-surface count and §8's admission-rule count moved in the same commit; §6.1 lists the two; the numbering appends 6.2.40–41 after 6.2.39 like 117/107 appended theirs. |
| 2026-09-13 | v1.32 | 123 §A table resolution | No new tools. §6.2.18 `datasources_get_columns` and §6.2.33 `datasources_get_table_stats`: an unknown table is now refused as **`datasource.table_not_found`** (was an empty list / empty stats), naming the nearest listed table when one is close — the module resolves the table with the same catalog call the tables listing uses. §6.2.19 `datasources_preview_rows`: the table is resolved before any statement runs (unknown → `table_not_found`), and a present-but-unreadable table (permission SQLSTATE) is **`403 datasource.table_forbidden`** — the same classification §6.2.34 `sql_probe` gains for a permission-refused statement. All four descriptions updated (inputSchemas unchanged); the LAKE registry branches are untouched. |
| 2026-09-11 | v1.30 | tempdb scratch probe + demo-free skill | §6.2.34 `sql_probe`: `name: "tempdb"` is a syntax-and-names check against an empty scratch H2 in the staging mode (was a refusal); pass = `parsed: true` + `missing_table`. Tool descriptions carry no sample-data names (`acme/finance/…`, `/finance/revenue/{region}`, `events_by_day`) — `SkillHasNoDemoContentTest` scans the skill and the rendered `tools.md`. New `template.validation.html_entity` (§13.9) on `templates_create`/`_update`. |
| 2026-09-11 | v1.29 | 117 templates_update | Tool surface 34 → **35**: new §6.2.36 `templates_update` — the draft write REST `PUT /templates` (§8.4) makes, the template mirror of `pipelines_update`: same parse-only validation as `templates_create`, same `TemplateDraftService.write` one-write-path rule, required `id` (§9.6) and `expected_hash`, copy-on-write first write / in-place later writes / the §5.1 no-op (`status: "RELEASED"`, no draft pointer), `template.not_found` / `template.version.conflict` / `template.validation.type_immutable` refusals. Scope `author`, **mutating**. No `confirm_new_root` argument: an update names a template that exists and cannot mint a folder (094's create-only rule). Before 117 the only MCP path to change a template was purge-and-recreate, and purge is refused once any pipeline pins the template — exactly the state an authoring agent is in mid-build (versioning D4 untouched: the agent writes the draft, a human releases). §6.1 lists it after `templates_create`; §5.1's static-surface count and §8's admission-rule count updated. The fence is drift-pinned to the shipped schema like every §6.2 block; the numbering APPENDS 6.2.36 for the same reason 107 appended 6.2.32–35 — 6.2.23/24 already appear twice and a mid-group insertion would renumber ~20 cross-doc anchors. auth.md §7.6's MCP table and the `ScopeMatrixSpecDriftTest` counts (34 → 35, both axes) moved in the same commit. |
| 2026-09-11 | v1.31 | 118 learned semantic layer | Tool surface 35 → **38**: new §6.2.37 `semantics_record` (one learned fact — kind from the closed enums.md §19 list, structural refs validated against the LIVE schema, `evidence_sql` run once through the probe path with its first rows as `evidence_summary`, `supersedes` retiring the predecessor; scope `author`, **mutating**, audited as `semantics.recorded`), §6.2.38 `semantics_list` (the facts a workspace can see, with `since`; scope `read`) and §6.2.39 `semantics_retire` (retire with a reason — a state, never a delete; a DATASOURCE fact another workspace established needs `ws_admin`; scope `author`, **mutating**). **New §6.2.18a**: the learned-fact block `facts[]` on `datasources_get` (datasource-wide kinds), `_get_tables` (table-grain facts and every `stale` one) and `_get_columns` (per column) — the §6 drift check runs at read on `/columns` and on a complete `/tables` listing and writes its demotion back; conflicts coexist (D-S5); `source_pipeline` renders only where the reader can read the pipeline (D-S9). The three descriptions (§6.2.11/17/18) say so. §5.1's static-surface count and §8's admission-rule count updated. Design record: `docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md`. |
| 2026-09-09 | v1.28 | 107 agent probes | Tool surface 30 → **34**: new §6.2.32 `templates_purge_draft` (the bounded D61/D62 self-service verb — hard-deletes a never-released, unpinned, author-owned draft template, the entity row with it; scope `author`, **mutating**), §6.2.33 `datasources_get_table_stats` (one table's catalog statistics — row estimate, indexes incl. the lake partition pseudo-index, per-column bounds — always from the engine's OWN catalog, never a scan; scope `read`: stored estimates about shape, never row data), §6.2.34 `sql_probe` (ONE classified read-only SELECT/WITH, row-capped at 500 and timeboxed at 30 s, answering rows + canonical schema + the EXPLAIN plan captured BEFORE the query — so the plan survives the timeout it explains; `tempdb` refused with the §6.2.20 code; scope `author`, the 037 F row-data rule) and §6.2.35 `executions_cancel` (cancel a RUNNING execution this key's OWN MCP calls started — the same-credential rule joins key id x correlation id on the audit log — `pipelines_execute` gained a launch-time `mcp.execution.launched` row for exactly this, because the dispatcher's row is written when the call ENDS and this call blocks until the execution is terminal; requested, not awaited; scope `execute`, **mutating**). §6.1 lists the four; §5.1's static-surface count and §8's admission-rule count updated. §6.2.3's abandoned-call paragraph now points at `executions_cancel`; §12's "an MCP cancel tool" future line removed (shipped). §14 records the one audit exception: a `sql` argument is logged as `sql_sha256` + `sql_length`, never verbatim. The four entries' fences are drift-pinned to the shipped schemas like every §6.2 block; the numbering appends 6.2.32–35 because 6.2.23/24 already appear twice (endpoints and calculators) and 6.2.29–31 are the lake tools. |
| 2026-09-08 | v1.27 | T199 LAKE in the MCP enum | No new tools. §6.2.6 and §6.2.8 `dialect` enums gain **`LAKE`** — the server accepted it since 087/089, the ADVERTISED schema still listed seven values, so clients refused every LAKE template before the server saw it (an agent blamed its own typing and bypassed MCP over REST). The enum is now derived from `Dialect.entries`; `DialectEnumSchemaTest` and the §6.2 drift test pin it. |
| 2026-09-08 | v1.26 | 099 draft-first (D55/D56) | **Additive: response VALUES and descriptions, no new tool and no new argument.** §6.2.4 `pipelines_create` lands version 1 as a **DRAFT** — the response carries `status: "DRAFT"`, `current_version: null` and the `draft` pointer, and the description tells an agent to run it and then STOP for a human to release (D4 without exception). §6.2.8 `templates_create` mirrors it. §6.2.5 `pipelines_execute` documents its default in the `version` property: with none given it runs the **WORKING** version — the draft when one exists, else the latest release (D56) — never clamped for an explicit one. §6.2.1 `pipelines_list` rows now state the working `version` and a new `status` (`DRAFT`/`RELEASED`), because a listing that reported the released pointer alone would show nothing for every freshly authored pipeline. The `datapipelines://pipelines/{id}` resource (no `/versions/{n}`) serves the working version too, so reading a body and running it cannot disagree. Tool count unchanged at **30**. |
| 2026-09-04 | v1.19 | 072 calculators | Tool surface 22 → **24**: new §6.2.23 `calculators_list` and §6.2.24 `calculators_get` (both scope `read`, non-mutating), projecting the `CalculatorRegistry` catalog — typed inputs, output type and a worked example per kind, plus the org and platform Context keys a body may reference without declaring anything. `pipelines_create` / `pipelines_update` need no new arguments (a CALCULATOR node is part of the body) but their descriptions now name the type and point at `calculators_list`. `executions_get` needed no change and gained two things anyway: `parameters` is now the fully resolved Context after the run (org keys, platform keys, parameters, calculator outputs — [DAG Executor §7.3](/docs/dag-executor.md#73-the-context-snapshot--pipeline_executionsparameters_json)) and each CALCULATOR node's `node_stats` entry carries `context_key`/`context_value`. §5.1's static-surface count and §8's admission rule updated. |
| 2026-09-04 | v1.18 | 068 datasources_create | Tool surface 21 → **22**: new §6.2.22 `datasources_create` (scope `author`, **mutating**), which calls the same `DatasourceCreateService` `POST /api/v1/datasources` does — one payload binder, one set of workspaces D8 rules, one duplicate-name refusal. `global: true` still requires admin, refused with `datasource.validation.workspace_forbidden`. The result is the datasources §3.2 shape with `password_set: true` and no password at any depth. The tool's description carries the accepted trade-off: a password passed through an agent transits its context, transcript and client logging — prefer the UI or REST for a real credential. §4.1, §5.1, §6.1 and §8's admission-rule counts updated, and §14's "no datasource management tools" omission narrowed to update/delete. |
| 2026-09-02 | v1.17 | 040 template used-by | Tool surface 20 → **21**: new §6.2.21 `templates_used_by` (which pipelines pin a template version in their working version — one reference per node with the carrying pipeline version; scope `read`, 040 D7). §6.2.2 `pipelines_get` gains `upgrade_available` (omit-when-empty; node/template/pinned/latest-released rows; surfaced, never applied). §6.1, §5.1 and §8 admission-rule counts updated. |
| 2026-08-05 | v1.0 | initial draft | Initial MCP server spec: streamable HTTP transport, API key auth, 15 tools, 8 resource types, 3 prompts, error model |
| 2026-08-05 | v1.1 | propagation | Updated `pipelines_create` tool to v1.1 Pipeline Contract shape (no `terminal_node_id`, no `datasources_used`; nodes carry `type`, `output`, `settings`). |
| 2026-08-10 | v1.3 | P6b build (Gate C) | Aligned the frozen spec with the merged `mcp-server` module. Additive/corrective only. **§3.1 implementation-gate RESOLVED**: protocol version pinned `2025-06-18` (negotiate-down), the v1 transport is **stateless** — `GET /mcp` optional and NOT served (405), no session ids, no resumability; SDK `mcp-sdk 2.0.0`. **§5.1**: `logging` capability **removed** — a stateless transport has no stream to deliver `notifications/message`, so advertising it promised notifications no client can receive. **§10**: marked not-delivered-in-v1 (defines the v2 shape only); `node_stats` in a tool's final result is the authoritative per-node record. **§6.2.3**: corrected the abandoned-call paragraph — a blocking `POST /mcp` has no disconnect callback, so `disconnect-grace` cancellation does **not** apply to an abandoned MCP tool call (only out-of-band `DELETE /executions/{id}` + the execution timeout do); result-shape enumeration now lists `ttl_seconds` (mirrors REST `data_ready`). §6.2.9 (bare rendered-SQL string) and §6.2.10 (`dialect` free `{"type":"string"}`, no enum) unchanged — the code was aligned to them. Rate limiting on `/mcp` (§13), repository limit/offset push-down, execution-record persistence and the admin all-executions listing are cross-surface carry-forwards to `web`/`app` (P6a/P7), not defects in this module. |
| 2026-08-07 | v1.2 | consistency campaign | Per [SPEC-REVIEW-2026-08](https://github.com/msabiransari/datapipelines/blob/main/docs/SPEC-REVIEW-2026-08.md) §2.11. **[D11]** §3.2/§4.1 auth rewritten: `DP-API-Key` **or** `Authorization: Bearer dpk_...` through one validation path; session JWTs explicitly rejected; security-chain note added (auth §8.5). **[D15]** Scope row on all 15 tools sourced from the auth §7.6 matrix; `read-only` → `read`; `admin` acknowledged as required by no v1 tool. **[D9]** §6.2.15 rewritten to the uniform REST §7 cursor (offset/limit/format, fixed TTL, stable order, ownership check), 1 MB inline cap with cursor-URL fallback, `result.*` error table; §6.2.3 returns first page + `result_url` (claim-check language gone). **[D3/D12]** `templates_create` drops `params_schema`, gains `imports [{id,version,alias}]`, `is_library`, `engine`; `templates_render` context is a free-form parameter map. **[D1]** `pipelines_create` node description: omitted `output` → `caller`, at most one caller node, zero legal. **[D7]** §6.2.3 documents blocking-call semantics, execution timeout, abandoned-call cancellation after grace, and deferral of MCP progress notifications. **[D10]** `X-API-Key` → `DP-API-Key`. **[M]** §5.1 `listChanged: false` across capabilities; §6.2.1 drops `datasources_used`; §7.3 `resources/list` pagination specified (opaque cursor, page size 100, 24h execution window, scope filtering); §8.2 `create_pipeline_for_question` removed from the v1 surface (ROADMAP §2); §3.1 verification marker reframed as an implementation-gate checklist; §12 futures re-tiered against ROADMAP; §13 checklist expanded. |
| 2026-08-14 | v1.4 | v1.1 introspection build | Tool surface 15 → **18**: new §6.2.16 `datasources_get_schema`, §6.2.17 `datasources_get_tables`, §6.2.18 `datasources_get_columns` — read-only JDBC metadata introspection (`author` scope, the `datasources_test` precedent), sourced from datasources §7A with canonical type mapping, 200-table snapshot cap, empty-list-for-unknown-filter. §6.1 lists the three; §5.1 static-surface count updated; §12 future-work bullet removed (shipped). |
| 2026-08-14 | v1.5 | v1.1 introspection build | §8 prompt surface 2 → **3**: `create_pipeline_for_question` (§8.2) returns with the introspection tools it depends on. Admission-rule paragraph rewritten (18 tools; all three prompts meet the bar). `question` argument: free text by design, length-capped at 2000 chars (`-32602` outside 1..2000), embedded in a delimited data-not-instructions block. |
| 2026-08-15 | v1.6 | surface restructure (part 1) | **`datasources_get_schema` removed** (§6.2.16 block deleted) together with its REST twin `GET /datasources/{name}/schema`: the bundled whole-schema snapshot bundled columns into the table listing; table listings stay lightweight so more tables fit in one response. Tool surface 18 → **17**; §6.1, §5.1, §8 admission-rule counts updated; the introspection flow remains `datasources_get_tables` → `datasources_get_columns` until the schemas listing lands. |
| 2026-08-15 | v1.7 | surface restructure (part 2) | New §6.2.16 `datasources_get_schemas` — the introspection flow's entry point (schemas → tables → columns). Tool surface 17 → **18**; §6.1, §5.1, §8 counts updated. §6.2.17 `datasources_get_tables` description + Returns now state the flow contract: the unfiltered listing spans schemas — pass each table's schema to `datasources_get_columns`; §6.2.18's description now states that without a schema argument only the connection's current schema is read. MySQL databases arrive as JDBC catalogs, so the schemas listing reads `getCatalogs()`; an empty list is valid on schemaless dialects. |
| 2026-08-15 | v1.8 | semantics via remarks | §6.2.17/§6.2.18: table and column descriptors gain `remarks` — the engine-stored comment from JDBC REMARKS, omitted when the driver/database has none. |
| 2026-08-15 | v1.9 | surface restructure (part 3) | §8.2 `create_pipeline_for_question` walkthrough rewritten to the three-step grounding flow: `datasources_get_schemas` → `datasources_get_tables(schema)` → `datasources_get_columns` for only the tables the SQL needs. The never-reference-unreturned-tables rule and the sentinel fence are unchanged. |
| 2026-08-15 | v1.10 | hardening round 3 (005 review fix-cycle) | §6.2.16: `datasources_get_schemas` returns a page `{\"schemas\": [...], \"truncated\": bool}` capped at 2000 (was a bare array). §6.2.17/§6.2.18: without a `schema` argument, a datasource reporting no current schema (database-less MySQL URL) fails with the catalogued `pipeline.execution.parameter_required` (recovered via `datasources_get_schemas`) instead of a merged/spanning answer — descriptions and Returns updated; blank remarks are omitted, never `\"\"`. Input schemas unchanged (output-shape and error-behavior changes only). |
| 2026-08-16 | v1.11 | hardening round 4 (007 review fix-cycle) | §6.2.11: `datasources_get` (and `datasources_list`, which shares the projection) now returns `introspection_include_schemas` when the allowlist is non-empty — omitted when empty, the same envelope as REST §3.2 — so an agent debugging schema visibility can see an allowlist is active. Output-shape change only; inputSchema untouched. |
| 2026-08-16 | v1.12 | hardening round 4 (007 review fix-cycle) | §6.2.17: `datasources_get_tables` no longer fails on a datasource reporting no current schema — the parameter_required guard is scoped to `datasources_get_columns` (6.2.18), the only operation with a merge hazard; tool description updated (inputSchema unchanged). |
| 2026-08-17 | v1.13 | pipeline composition | §6.2.4/§6.2.5: the `nodes` inputSchema description now covers the PIPELINE node type (pipeline ref pinning, parameter literals and `${parent_param}` references, output legality). Runtime behavior is unchanged — composition executes through the internal execution service, not a new tool. |
| 2026-08-28 | v1.14 | workspaces surfaces slice | §2 principle 6 + §5.1 `instructions`: the workspace context statement (key-pinned scope; other workspaces absent, not hidden). §6.2.10/§6.2.11 descriptions + Returns gain `workspace`/`readonly`; datasource listings/by-name reads are workspace-scoped (bound + global — the REST §9.2/§9.3 predicate). §7.2.3/§7.3: the same scoping for datasource resources and `resources/list`. No new tools, no inputSchema changes. |
| 2026-09-01 | v1.15 | agent data visibility (037) | Tool surface 18 → **20**: new §6.2.19 `datasources_preview_rows` (≤50 wire-encoded rows of one table, `order_by` as `{column, direction}` objects, service-built + dialect-quoted statements, readonly datasources valid) and §6.2.20 `pipelines_execute_node` (ONE node's rendered SQL on its own datasource — a debug query, not an execution: no history/SSE/idempotency, DML/DDL for real, tempdb-source and PIPELINE nodes refused with `pipeline.node.standalone_execution_refused`, unknown node `pipeline.node.not_found`, E5 draft-if-exists version default with status always stated). §6.1, §5.1, §8 admission-rule counts updated. Both `author`: the first tools returning arbitrary customer row data (037 F). |
| 2026-09-02 | v1.16 | MCP audit (052) | New **§14 Audit** (normative, ruling R4): `mcp.tool.called` (every call, since the original build) registered + `mcp.tool.write` (NEW — exactly one per catalog-declared mutating call, node runs included, after the tool returns on success and failure; scope refusals excluded because the tool never ran). Emitted at the dispatcher, not per-tool. Mutating is a declared catalog-entry property guarded by `McpToolCatalogBindingTest`. Never SQL/row data/parameter values. §13 gains the mutating-call checklist line. Both events registered in Enums §15 the same commit (docs-audit check C). No tool surface change. |
| 2026-09-05 | v1.18 | pipeline folders (067) | Additive arguments only — **no new tool names**, the surface stays 21. `pipelines_list` and `templates_list` each gain **`prefix`**: absent = the flat listing (unchanged); present (`""` = the root) = ONE level of the folder tree, returning `{prefix, folders[{path, segment, *_count}], pipelines|templates[], total, has_more}`. `q`/`owner`/`datasource` are ignored while browsing; an illegal prefix answers an empty level, not an error. This closes a real gap for templates, which have had path ids since 043 and no way to browse a folder over MCP (verified 2026-09-04). `pipelines_create`/`pipelines_update` `name` patterns widen to the path grammar — rendered from `PipelineNameGrammar.pattern` itself, so the schema and the server rule cannot drift — with a description telling the agent to list the roots first and ask before minting one. §6.2.1 and §6.2.6 document browse-vs-search. |
| 2026-09-05 | v1.19 | published endpoints (074) | Tool surface 24 → **28**: `endpoints_create` / `endpoints_list` / `endpoints_get` / `endpoints_delete` (§6.2) — publish a released, side-effect-free pipeline as a `GET` endpoint and bind endpoint-kind keys to it. `create`/`delete` are `author`, the reads `read` (auth.md §7.6). **An endpoint-kind key cannot reach `/mcp` at all** (refused at `McpAuthFilter`; `/mcp` is a servlet outside `ScopeInterceptor`'s reach — security pass). No `api_keys_create` tool: a credential must not transit an agent's transcript. |
| 2026-09-05 | v1.20 | mandatory folders (077) | No new tools; two `pattern`s and two descriptions. §6.2.4/§6.2.5 `pipelines_create`/`pipelines_update` `name` narrows to the 2–10-segment grammar ([Template Hierarchy §4.1](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md#41-grammar)) — rendered from `PipelineNameGrammar.pattern` itself, so it moved with the rule. §6.2.8 `templates_create` `id` **gains a `pattern` for the first time** and it is `TemplateNameGrammar.pattern`: the schema had been advertising the pre-043 flat `[a-z0-9_.-]+` in prose, three grammar changes stale (audit T129, 2026-09-05). Both descriptions now state that a folder is required, that `details.reason='folder_required'` is how the refusal is recognised, and that experiments go under `test/`. An omitted `templates_create` id is generated under `test/`. |
| 2026-09-07 | v1.21 | 087 connector seams | No new tools. §6.2.22 `datasources_create` gains the `credential` object ([Datasources §3.4](/docs/datasources.md#34-credential-kinds)) and drops `username`/`password` from `required` — either shape is accepted, both together are refused; the dialect enum gains `LAKE`; the result carries `credential.kind` and a derived `password_set`. §6.2.16 `datasources_get_schemas` returns `entries: [{namespace, label}]` beside the legacy `schemas` array — two catalogs' same-named schemas are two entries, which a list of bare labels could not express. §6.2.17/§6.2.18 gain a `namespace` array argument beside `schema` (which now also accepts the dotted form), and every table row carries `namespace` beside `schema`. |
| 2026-09-07 | v1.20 | dp-lake registry (089 §A) | Tool surface 28 → **31**: `lake_tables_register` / `lake_tables_import` / `lake_tables_unregister` (§6.2.29–31) — the dp-lake catalog (metadata-db §4.15): register one table, bulk-import a manifest.json `tables[]` block (inline or fetched server-side from the datasource's OWN endpoint/bucket only — the SSRF boundary), unregister. All three are `author` (auth.md §7.6) and declared `mutating`. |
| 2026-09-08 | v1.22 | 089 Iceberg location correction | No surface change — two DESCRIPTION strings corrected to the measured rule (datasources.md §8C.7): §6.2.29's tool description and its `location` property now say an Iceberg table's location is the current metadata FILE (`…/metadata/00042-<uuid>.metadata.json`), not the table root — DuckDB 1.5.5 cannot scan a pyiceberg table by its root, so an agent following the old text registered a location whose view fails at connect. The code strings and the fence moved together (the §6.2 fences are drift-pinned to the shipped schemas). |
| 2026-09-08 | v1.23 | 094 the agent boundary | Tool surface 31 → **30**: `datasources_create` REMOVED. §6.2.22 becomes the policy it was an exception to — **no credential travels through an agent**; the section number is kept so §6.2.23 onward do not shift. 068 accepted the hazard with a warning in the tool's own description; a description is not a control. Datasource create, update and delete are UI/REST-only; every read and probe tool is unchanged. §4.1's scope-enforcement paragraph, §5.1's `listChanged` count, §6.1's list, §6.2.10's scope note, §8's admission-rule count and §14's omission entry all follow. |
| 2026-09-08 | v1.24 | 094 new-root confirmation | §6.2.4 `pipelines_create` and §6.2.8 `templates_create` gain **`confirm_new_root`** (boolean) and REFUSE a name whose root segment has nothing under it yet — `pipeline.validation.new_root_requires_confirmation` / `template.validation.new_root_requires_confirmation` ([Pipeline Contract §13](/docs/pipeline-contract.md#13-error-code-catalog)), with `details.root` and `details.existing_roots` from the same one-level query `pipelines_list`/`templates_list` `{prefix: ""}` serve. `test/` is exempt; an omitted `templates_create` id (generated under `test/`) is exempt. **Agent surface only**: REST, the UI and `pipelines_update` are unchanged, and no tool other than these two accepts the argument. 067/077 had already told an agent to list the roots and ask; this is the same sentence as a guarantee. No tool-count change. |
| 2026-09-08 | v1.25 | 095 skill distribution | **Additive.** New **§15 The skill: how an agent learns this server** — one source (`.agents/skills/datapipelines/`), four deliveries: the `initialize` handshake (§5.1, now the operating core distilled, capped at 4096 bytes and living in a resource file), the MCP resource, the unauthenticated `GET /skill.md` route, and the Claude Code plugin. §7.1 gains two URI forms — `datapipelines://docs/skill` and `…/skill/{reference}` (§7.2.4) — read-scope Markdown served from the packaged copy; §7.3's enumeration order gains `docs` at the FRONT (the only constant-size kind). No tool surface change: the count stays **30**, and `references/tools.md` is now RENDERED from `McpToolCatalog` rather than typed, drift-tested against the committed file. |
