**[UluOps](https://uluops.ai)** · The operations layer for agentic work --- # @uluops/ops-mcp [![npm version](https://img.shields.io/npm/v/@uluops/ops-mcp.svg)](https://www.npmjs.com/package/@uluops/ops-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js Version](https://img.shields.io/node/v/@uluops/ops-mcp)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/TypeScript-5.7+-blue.svg)](https://www.typescriptlang.org/) [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](src/__tests__/) MCP (Model Context Protocol) server for the UluOps tracker API — runs, findings, issues, analytics, the project log (`get_project_log`, `get_log_stat`) and org management (`rehome_project`, `get_org_audit_feed`). Provides **55 tools** and **3 resources** (2 functional, 1 template placeholder) that let Claude Code, Claude Desktop, Codex, Cursor and other MCP hosts read and write the tracker. ## Analysis type attribution (Unreleased) `save_run`, `update_run` and their preview schemas accept `agent_type` on analysis records and summaries. Use `agent_name` on each typed row in a multi-agent run. Registered agents take their type from their exact saved execution version: do not declare one. A conflicting declaration is overridden, and the row is stored with the registry type and `agent_type_source: conflict` (ops-api R0, analysis-never-loses-the-run). Before R0 it rejected the whole save. Unregistered agents may declare their type. On a multi-agent run, a declaration without `agent_name` is discarded. Analysis reads retain the captured type, source and definition ID/version. `get_project_analysis` and `query_analysis_records` accept `agent_type: "unknown"` for unresolved or historical inferred attribution. The F02 API and SDK must ship together in the coordinated release. ## Table of Contents - [Design Philosophy](#design-philosophy) - [Installation](#installation) - [Configuration](#configuration) - [Usage with Claude Code](#usage-with-claude-code) - [Quick Start Examples](#quick-start-examples) - [Rate Limiting Configuration](#rate-limiting-configuration) - [Available Tools](#available-tools) - [Available Resources](#available-resources) - [Security](#security) - [Development](#development) - [License](#license) ## Design Philosophy **Thin Client Pattern**: This MCP server contains **zero business logic**. All data processing, validation, storage, and analytics are handled by the backend API. The server's sole responsibility is protocol translation between MCP's stdio-based JSON-RPC and the backend's REST API. ## Installation Requires **Node.js 20.3** or later. **Option A — npx (no install):** ```bash npx -y @uluops/ops-mcp ``` **Option B — global install:** ```bash npm install -g @uluops/ops-mcp ``` This exposes the `uluops-ops-mcp` binary on your PATH. After upgrading, restart the tracker MCP connection in your host so it loads the new server and tool schemas. To stay on a known version, pin it — `npx -y @uluops/ops-mcp@0.22.0` in the registration, or `npm install -g @uluops/ops-mcp@0.22.0`. Rolling back is the same command with the older version (then restart the connection). Unpinned `npx -y @uluops/ops-mcp` picks up new releases on its own schedule; 0.22.0 changes behaviour (see the CHANGELOG). ### Agent metrics transport `save_run`, `validate_run` and `update_run` preserve each agent's `harness` and the optional `cached_input_tokens`, `reasoning_output_tokens`, `thinking_tokens` and `tool_tokens` counters. For save/validate, counters belong in the agent's `tokens` object; for update, they are flat agent fields. Supply only observed components: missing measurements should not be invented as zero. SDK 6.5.2 also preserves the API's optional, nullable `modelRaw` alongside the normalized `model` in saved and retrieved agent snapshots. ### Rejected analysis on `save_run` (0.35.0) Since ops-api's "analysis never loses the run" release (live 2026-11-03), invalid analysis no longer fails a save. The server keeps the run, its issues and every other agent's analysis, and rejects **one agent's analysis as a unit** (its records and summary together). The response's `analysisWrite.rejected[]` lists each rejected agent with `reason`, counts and `detail.issues`, and `save_run` adds a plain-text notice block before the untrusted-content notice: ```text [ops-mcp notice, computed by this MCP server from the tracker response] Run #7 is saved. The server rejected the analysis of: "agent-a" (entry_invalid, 1 issue; 2 records and 1 summary not stored). Do not call save_run again: that creates a second run. To repair, add the corrected analysis to this run with update_run (run_id …), sending each rejected agent's complete record set and summary under its agent_name: none of its entries were stored. Preview with preview_update_run first: an update is refused whole if any entry is invalid, and analysisWrite.rejected[].detail.issues lists at most 10 issues per agent (truncated: true means there are more). If you do not repair it now, tell the user which agents' analysis is missing from run #7. ``` The block is labelled as this server's own text, because the untrusted-content notice after it warns about instruction-like tracker data. It is absent when nothing was rejected. An entry that is its own unit (no usable `agent_name`) gets one more sentence: repair it with `record_write_mode: "merge"`, because under the default replace a nameless repair would supersede the other rows stored under the run's default name. On a deduplicated replay, which carries the original save's `rejected[]`, the block says the rejection may have been repaired since. `validate_run` returns `wouldReject[]`, the same prediction before anything is saved, with its own notice block. On a multi-agent run, `save_run` and `validate_run` also **warn** (never refuse) when analysis entries carry no `agent_name`: the tracker stores them under the run's default name, the misattribution the old refusal prevented. **The tool schemas stay typed** (spec OQ-6, "keep"), as the generation guide for analysis entries, and are **no stricter than the server's save path**. A save-path `agent_name` that is empty or whitespace-only now passes and counts as absent, as the server treats it, and `record_id` has no minimum. `src/__tests__/save-schema-server-parity.test.ts` checks this against a fixture derived from the server's own schemas. `update_run` and `preview_update_run` keep the stricter entry rules: the server refuses an invalid update whole, and an update creates no run. ### Attribution warnings on `save_run` and `validate_run` The tracker credits a run to an agent version only when that agent's own `definition_version` is sent. Without it the server stores a fallback (`inferred-latest`, or nothing) that no version record counts. A saved run cannot be relabelled, because `update_run` does not carry the version. When a payload would lose attribution, a successful response carries a `{ "warnings": [...], "from": "..." }` block. It sits after the context block and before the untrusted-content notice. `from` marks it as computed by this server from your own request, not tracker data. The block names: - **Hand-built agents with no version.** These have no `definition_version` and no `agent_id`. Splice from `agent-metrics … -f tracker`; if there is no agent-metrics output, leave the version out. - **Spliced agents with no version.** These have an `agent_id` but no version: a *note, no action needed*. agent-metrics omits the version when it cannot name the definition that ran (built-in or plugin agents, no `version:` in the file, a file changed around spawn). - **Agents with no `agent_id`.** These are probably hand-built. Never invent or reuse one: it is the only join key to the transcript. - **A reused `agent_id`.** - **Hand-built versions that may have been copied.** These match the run's `definition_version` under another name, or are shared by differently named agents (tracker c18f1ab1). Versions on spliced entries (with an `agent_id`) are trusted: they were captured at spawn, and a label that merely coincides with the run's is not a copy. For example, a pipeline run spliced from agent-metrics in which one agent was the built-in `Explore` (no definition file, so agent-metrics omitted its version) returns this block from `save_run`. It was generated by running the shipped function on that payload: ```json { "warnings": [ "Note, no action needed: 1 agent has no definition_version (Explore). agent-metrics omits it when it cannot name the definition that ran (a built-in or plugin agent, no version in the file, or a file changed around spawn); the run is counted as unattributed for these agents. Leave it omitted. Never read a version from the agent file, look one up in the registry, or copy the run's: an omitted version is counted as a miss, while a guessed one can credit the wrong version and hides the miss." ], "from": "this MCP server, computed from your own request — not tracker data" } ``` Empty, whitespace and `unknown` values count as missing. The agent a type-`agent` run is named after inherits the run version on the server and is not reported. That includes a single-agent run with both `definition_type` and `definition_name` omitted, which the server infers. A name given without a type does not inherit. Every version warning says what not to do as well as what to do. Never read a version from the agent file, look one up in the registry, or copy the run's: an omitted version is counted as a miss, while a guessed one can credit the wrong version and hides the miss. **Preview first.** `validate_run` shows the same warnings worded for before the write ("fix this before calling save_run"). It accepts the run-level `definition_type`, `definition_name` and `definition_version` so it can run the copy check; these are used only for the warnings and are not forwarded to the API. On `save_run` the run is already recorded. The warning then says so, and says not to re-save: a corrected payload has a different content-derived idempotency key, so a re-save creates a second run. The warnings never refuse the call, never fill a value, and never change what is sent to the API. A clean payload carries no block. A failure inside the warning code is logged to stderr and never turns a recorded write into an error. ## Configuration Set environment variables in your MCP host configuration (see "Usage with Claude Code" below) or in a `.env` file when developing locally. | Variable | Description | Required | |----------|-------------|----------| | `ULUOPS_API_KEY` | API authentication key (must start with `ulr_`, min 20 chars). Create and manage keys at [app.uluops.ai/settings/api-keys](https://app.uluops.ai/settings/api-keys) | Yes | | `ULUOPS_ORG_SLUG` | Lowest-precedence org default (see *Which org a call lands in*) | No | | `ULUOPS_ORG_ALLOW` | Comma-separated orgs this server may EVER target. Unset = unbounded, warned at boot | No (set it) | | `ULUOPS_ALLOW_DESTRUCTIVE` | `false`/`0` disables the 12 destructive tools (they refuse with `DESTRUCTIVE_NOT_ARMED` before any request); `true`/`1` enables them and silences the boot warning. Unset = enabled, warned at boot. Any other value refuses to start | No (default: enabled) | | `ULUOPS_TRACKER_TIMEOUT` | Request timeout (ms) | No (default: 30000) | | `ULUOPS_TRACKER_RETRIES` | Number of retry attempts on failure | No (default: 3) | | `ULUOPS_BASE_URL` | Override the backend API base URL (e.g. a local or staging deployment). Non-HTTPS values log a cleartext-credentials warning | No (default: `@uluops/ops-sdk`'s production URL) | | `NODE_ENV` | When `development`, `@uluops/ops-sdk` **defaults the base URL to localhost** if `ULUOPS_BASE_URL` is unset, and the non-HTTPS warning is silenced. Unset it (or set `ULUOPS_BASE_URL`) if tool calls unexpectedly target localhost | No | | `LOG_LEVEL` | Logging level (`debug`, `info`, `warn`, `error`) | No (default: info) | The backend URL is handled automatically by `@uluops/ops-sdk` — production by default; set `ULUOPS_BASE_URL` only when targeting a non-production deployment. ### Which org a call lands in Every tool takes an optional `org` (slug) → `X-Org-Slug` on that request. Omit it and the server resolves a default **per call**: the nearest `.uluops.json` above the process's launch directory (`{ "org": "ulu-labs" }` at the root of a work checkout; `{ "org": "personal" }` in a personal repo nested under it stops the walk — the walk never rises above your home directory and a file owned by another user is refused), else `ULUOPS_ORG_SLUG`, else no org override. Bound keys resolve to their bound org; unbound credentials may use a personal default. The API never infers an org from a project name. The first result block keeps the data payload; a second JSON block reports `requestedContext: { orgSlug, source }` separately from `effectiveContext: { version: 1, orgSlug, source }`. Requested sources are `explicit`, `workspace`, `environment`, or `omitted`; effective sources are `bound-key`, `request`, or `personal-default`. Old/malformed server metadata yields `effectiveContext: null` with an unavailable note, while successful data stays usable. Error results retain same-response context when available, without asserting that a write committed. Logs describe the requested scope, not proof of the destination. The file may carry only `org`, `project` and `$schema` (the `@uluops/ops-sdk` ≥ 6.5.0 allowlist); anything else is refused. The startup log line names the resolved default and its source. **The `org` value must come from the user.** Tool results carry text written by other tracker users (issue notes, recommendations, descriptions) and come back indistinguishable from the operator's words; every tool's `org` description says so, every successful result ends with a notice saying so, and `ULUOPS_ORG_ALLOW` bounds what the server will accept regardless: an org outside the list — whether it came from the argument, the workspace file or the env — is refused before any request with a terminal `ORG_NOT_ALLOWED` that names the list. `personal` is always allowed. Leave it unset and every org the key holder belongs to is reachable; the boot log warns, and (since 0.22.0) so does every call that resolves a named org — the source org and `rehome_project`'s `target_org`. The bound covers **both orgs of a two-org call**: `rehome_project`'s `target_org` is checked against the same list before any request, so the server can neither read from nor move a project *into* an org the operator excluded. Five refusals are terminal and say so in the tool result: `INSUFFICIENT_ORG_ROLE` (your role in that org is below `publisher` — do **not** retry without `org`, which changes the requested scope), `ORG_ACCESS_DENIED` (not a member, or a bound key), `ORG_NOT_FOUND` and `ORG_SUSPENDED` (the named org does not resolve / is suspended — same rule, do not drop `org`), and `PROJECT_REHOMED` (the project moved orgs; the result names the org to pass). `ORG_NOT_ALLOWED` is the server-side sixth, and `DESTRUCTIVE_NOT_ARMED` (the operator set `ULUOPS_ALLOW_DESTRUCTIVE=false`) the seventh. `update_run` reports `FINALIZED_RUN_FIELD_IMMUTABLE` when an already recorded score or gate result is changed. The error names `immutableField` and `applicationState: not_applied`; token and analysis enrichment remain available. Field validation errors retain paths in `field_errors` without repeating the same text in `error`. A response-schema failure after a write remains application-unknown: read current state before retrying. **Moving a project between orgs** is `rehome_project` — the member path of the spec's §4.1. Two arguments name two orgs and both the description and the `org` field's own schema text say which is which: `org` is where the project is *now* (the API looks it up there — omit it for a work-org project and it looks in the resolved default org, moves a same-named project if one lives there, and otherwise 404s; never a search), `target_org` is where it goes. Every result's context block includes `requestedContext`, `effectiveContext`, and `targetOrg: "ulu-labs"`, and the per-call log record carries `targetOrg`. A `same_org` 400 means "already there" and the result says so (`terminal`, `applied: false`, with the API's `orgSlug`) — but note it is the idempotence signal of the *admin* path only: the member path looks the project up in the **source**, so a re-run after the move answers **404**, and the 404 text says so and tells the model to check the target with `get_project` before doing anything else. The other refusals name their reason too (`name_collision`, `soft_deleted_conflict`, `rehomed_away_conflict`, `project_soft_deleted`, `export_in_progress`, `moved_during_request`, …) and a 402 `PROJECT_LIMIT` is described as the target's cap, not a subscription gate. What moved, and who moved it into a personal org, is readable by any member through `get_org_audit_feed` — **minus the operator's free-text `reason`**, which this server never relays (spec §4.4a: it is text written by one member and read by another, one line away from an instruction); the CLI, read by a human, shows it. A feed entry carries `actorKind` (`user`, `system:org_lifecycle` for the org purge, or `unknown`) when the server sends it: identify who acted by it, never by `details` text. This feed holds org-visible moves only, so the absence of system rows is not evidence that no automated activity happened. ### Advanced Logging By default this server logs only to stderr at `info` level. To enable structured file logging, set: | Variable | Description | Default | |----------|-------------|---------| | `ENABLE_FILE_LOGGING` | Write JSON logs to disk | `false` | | `LOG_DIR` | Directory for log files | `logs` | | `VERBOSE_LOGGING` | Include extra diagnostic detail | `false` | | `LOG_PERFORMANCE_METRICS` | Emit per-call timing metrics | `false` | When `ENABLE_FILE_LOGGING=true`, a `logs/` directory is created in the process's working directory. ### Production Tightening | Variable | Description | Default | |----------|-------------|---------| | `ENABLE_DETAILED_ERRORS` | Propagate redacted error reasons in `error.message` so callers can diagnose failures without parsing the data envelope. Set to `false` to suppress in tightened production deployments. | `true` | ## Usage with Claude Code Add to your Claude Code MCP configuration (`.mcp.json`): **Option 1: npx (lowest friction — no install)** ```json { "mcpServers": { "uluops-ops": { "command": "npx", "args": ["-y", "@uluops/ops-mcp"], "env": { "ULUOPS_API_KEY": "ulr_your-api-key-here" } } } } ``` **Option 2: Globally installed binary** ```json { "mcpServers": { "uluops-ops": { "command": "uluops-ops-mcp", "args": [], "env": { "ULUOPS_API_KEY": "ulr_your-api-key-here" } } } } ``` ## Usage with Claude Desktop and Codex The same server and env vars work in any MCP host; only the config file differs. **Claude Desktop** — `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`): ```json { "mcpServers": { "uluops-ops": { "command": "npx", "args": ["-y", "@uluops/ops-mcp"], "env": { "ULUOPS_API_KEY": "ulr_your-api-key-here" } } } } ``` A Dock-launched Claude Desktop does not inherit a shell's `nvm` PATH; if `npx` is not found, put the absolute path from `which npx` in `command`. **Codex** — `~/.codex/config.toml`: ```toml [mcp_servers.uluops-ops] command = "npx" args = ["-y", "@uluops/ops-mcp"] env = { ULUOPS_API_KEY = "ulr_your-api-key-here" } ``` ## Quick Start Examples Once configured, Claude Code can use the uluops tracker tools. These are MCP tool invocations (issued by the MCP host), not runnable TypeScript: ```text // Save a run — the findings a pipeline produced against the project save_run({ project: "my-project", workflow_type: "ship", agents: [ { name: "code-validator", score: 85, decision: "PASS" }, // Scoreless agents (generators, executors) omit score and max_score — // do not fabricate a score. They are stored as null, not 0/100. { name: "aristotle-generator", decision: "ACTUALIZED" } ], recommendations: [ { agent: "code-validator", title: "Fix lint error", priority: "suggested" }, // Two agents, one adjudicated defect: give both rows the same cluster_key // (≤64 chars) so the tracker records within-run convergence instead of // two unrelated findings. Omit it when the pipeline has no adjudicating stage. { agent: "security-analyst", title: "Refresh token replayable", priority: "high", cluster_key: "auth-refresh-replay" }, { agent: "circumvention-forecaster", title: "Refresh token reusable after rotation", priority: "high", cluster_key: "auth-refresh-replay" } ] }) // Query open issues for a project query_issues({ project: "my-project", status: "open", priority: "critical" }) // Get project summary with issue counts and trends get_project_summary({ project: "my-project" }) // Consolidate a duplicate project into the canonical one. // ALWAYS dry_run first — the merge is durable (no undo). The preview reports // exactly what would move (runs, issues, dedupes) without changing anything. merge_projects({ source: "-my-project", target: "@org/my-project", dry_run: true }) merge_projects({ source: "-my-project", target: "@org/my-project" }) // execute // Enrich a saved run with analysis data. Analysis writes are PER-AGENT: // under the default replace, each named agent's entire live set is superseded // by these rows and omitting a record RETIRES it; under // record_write_mode: "merge", matched (agent_name, record_id) keys are // superseded, unmatched keys append, and nothing is retired. Preview first // when updating an agent that already has records — would_retire_record_ids // shows what a replace omission would destroy, without writing anything. preview_update_run({ project: "my-project", run_number: 5, analysis_records: [{ agent_name: "nietzsche-analyst", record_type: "convention", record_id: "C-1", title: "Inherited convention", data: {} }] }) update_run({ project: "my-project", run_number: 5, analysis_records: [{ agent_name: "nietzsche-analyst", record_type: "convention", record_id: "C-1", title: "Inherited convention", data: {} }] }) ``` ```text // The project's second history — runs and decisions interleaved, newest first get_project_log({ project: "my-project", kind: ["decision", "regression"], limit: 20 }) // The rollup: examined / found / decided / cameBack for the last 30 days get_log_stat({ project: "my-project" }) // Analytics and the failure taxonomy get_taxonomy({}) get_burndown({ project: "my-project", granularity: "weekly" }) // Reading structured analysis back — summaries per run, records across runs get_project_analysis({ project: "my-project", agent_name: "nietzsche-analyst" }) get_run_analysis({ run_id: "" }) // by UUID, not run_number query_analysis_records({ record_type: "convention", classification: "CALCIFIED", limit: 20 }) get_agent_runs_analysis({ agent_name: "nietzsche-analyst", project: "my-project" }) // Org management — move a project to another org, then read the org's activity rehome_project({ project: "my-project", target_org: "ulu-labs", reason: "work project, wrong org" }) get_org_audit_feed({ limit: 20 }) ``` If an analysis-bearing `update_run` fails with `AnalysisEchoMismatchError`, the update **was already applied** — the error means server and SDK disagree about analysis-write semantics, not that the write failed. Do **not** retry (a retry re-applies the write); re-read the run (`get_run_analysis`) instead. ## Rate Limiting Configuration This server uses `mcp-secure-server` with configuration optimized for Claude Code's usage patterns. ### Claude Code Usage Patterns | Operation | Typical Parallel Calls | Notes | |-----------|------------------------|-------| | Query context (summary, issues, runs) | 3-5 | Low burst | | Create issues from validation workflow | 10-30 | High burst | | Update agents with metrics | 6 | Medium burst | | Save a run (`recommendations[]`) | 1 (with array) | Single call | Claude Code issues tool calls in short, intense bursts (<2s) followed by "thinking" pauses. The default configuration accounts for this: ```typescript { securityLevel: 'basic', maxRequestsPerMinute: 120, burstThreshold: 15, // Covers 90% of parallel operations burstWindowMs: 5000, // 5s window resets between thinking periods automationDetection: { enabled: false, // Claude Code is trusted automation }, } ``` ### Why These Settings - **`burstThreshold: 15`** - Handles typical validation workflow bursts (10-15 parallel issue creates or queries) - **`burstWindowMs: 5000`** - Short window resets between Claude's "thinking" periods, preventing false positives - **`automationDetection: disabled`** - Claude Code IS automation with consistent timing patterns; detecting it as a "bot" would block legitimate use ### Payload Size Limits `save_run`, `update_run`, and `preview_update_run` carry the largest payloads on the surface: long `raw_markdown` reports, plus hundreds of recommendations with per-agent analysis summaries (the preview accepts the same analysis payload the write does). `mcp-secure-server` enforces payload size at several independent layers, server-wide, before any tool's own policy. This server sets each to what the tracker API itself accepts, so the API, not the transport, decides what a run may contain (`src/config/message-limits.ts` has the derivation): ```typescript { maxMessageSize: 2 MiB + 64 KiB, // Layer 1 envelope: the API's 2 MB body limit, plus JSON-RPC headroom maxStringLength: 512 * 1024, // Layer 1 per-string cap: the API's raw_markdown max is 500,000 chars maxParamBytes: 2 MiB + 64 KiB, // Layer 2 serialized-params cap suspiciousMessageSize: 2 MiB + 64 KiB, // Layer 3 large-message block maxParamCount: 32_768, // keys counted recursively; a save_run at every API array limit is 17,498 } ``` A save_run at the API's limits (100 agents, 500 recommendations, 100 analysis records, 20 summaries, a 500,000-char report) passes the transport; `message-limits.test.ts` runs exactly that payload through the real validation pipeline. The tool schemas also refuse what the API refuses, by name: `agents` at most 100, `recommendations` at most 500, `raw_markdown` at most 500,000 chars. When a payload is rejected at the transport, the error names which cap fired. For a per-string rejection it names the field path (e.g. `raw_markdown`). These limits apply to every tool on this server. Each tool's `maxArgsSize` still caps its own payload in bytes. Two transport checks are not configurable. Nesting deeper than 15 levels is refused. Invisible and direction-control characters are handled as follows (mcp-secure-server 0.1.1-security): - **Refused anywhere:** the bidirectional controls U+202A–U+202E and U+2066–U+2069, plus U+200B, U+2060 and U+FEFF. - **Allowed where they carry meaning:** the zero-width joiners, inside an emoji sequence or in a script that shapes with them. Compound emoji in a report pass; a joiner hidden between Latin letters is refused. **Byte caps are UTF-8 bytes** (since 0.21.2, on `mcp-secure-server` 0.0.23-security and later): `maxMessageSize`, `maxParamBytes`, `suspiciousMessageSize` and `maxArgsSize` count encoded bytes, so non-ASCII text uses the envelope faster — an em dash or `→` is 3 bytes, most CJK characters 3. `maxStringLength` stays a character count. Earlier releases measured UTF-16 code units, which let a non-ASCII payload up to ~3x the stated size through. ## Available Tools ### Core Tools (P0) | Tool | Description | |------|-------------| | `save_run` | Save validation pipeline output with issue correlation | | `query_issues` | Query issues with filtering by status, priority, validator | | `update_status` | Update issue status (completed, deferred, wontfix) | | `get_project_summary` | Get project overview with workflow and validator stats | | `delete_project` | Delete project data (requires confirmation) | ### Extended Tools (P1) | Tool | Description | |------|-------------| | `create_issue` | Create a user-submitted issue directly (outside validation runs) | | `get_issue_details` | Full issue lifecycle with occurrences, notes, history | | `get_run_details` | Run information with all recommendations and stats | | `diff_runs` | Compare two validation runs (fixed, new, unchanged issues) | | `archive_runs` | Archive old runs without deletion | | `get_analytics` | Eight metrics; list metrics support negotiated `format=page` with totals and implementation metadata. Legacy shapes and limit20 default remain. | | `search_issues` | Search issues across projects with relevance ranking | | `list_agents` | Discover agent names recorded in run history; default lists the full 30-day result, `format=page` opts into filters and paging | | `validate_run` | Preview save operation without modifying database | | `get_issue_history` | Merged audit-event stream (occurrences, status, notes) as an envelope `{issueId, events[], totalEvents, truncated}` — includes undo tombstones (v0.4.0+) | | `add_issue_note` | Add context, resolution, or blocker notes to issues | | `edit_issue` | Edit issue metadata (title, file_path, severity, etc.) | | `merge_issues` | Merge duplicate issues into a target issue | | `bulk_update_status` | Bulk update multiple issue statuses in one transaction, by issue UUID across the org (`project` is informational, not a scope) | | `update_run` | Enrich run telemetry and set unrecorded score/gate fields; identity and timestamp are immutable; changing an already recorded `averageScore` or `allGatesPassed` is refused with `FINALIZED_RUN_FIELD_IMMUTABLE`. Per-agent analysis writes use replace (default) or merge via `record_write_mode`; analysis-bearing responses carry the `analysisWrite` echo (camelCase response key) | | `preview_update_run` | Read-only preview of an analysis-bearing update under the requested mode (metadata and quality edits are not previewed): per agent, what the write would supersede, create, and (replace only) retire | | `get_agent_reliability` | Analyze agent effectiveness: false-positive, declined (wontfix) and resolution rates plus reliability score | | `get_agent_lifecycle` | Lifecycle metrics for an agent across runs | ### Project Tools (P2) | Tool | Description | |------|-------------| | `list_projects` | List all active projects | | `get_project` | Get a single project by ID or name | | `get_project_trends` | Get issue trends over time for a project | | `create_project` | Create a new project | | `update_project` | Update a project name | | `soft_delete_project` | Soft delete a project (can be restored later) | | `restore_project` | Restore a soft-deleted project | | `update_profile` | Update the authenticated user's profile (username, name, bio, timezone, websiteUrl). Setting `username` confirms it **one-time** — required before creating or publishing registry definitions | | `merge_projects` | Merge one project into another — runs and issues re-keyed into the target, colliding issues deduplicated by fingerprint, source soft-deleted. Durable (no undo) — always `dry_run` first | | `rehome_project` | Move a project and its whole history into another org (project-org-routing-and-rehome §4.1). **`org` is the SOURCE** (where the project is now), `target_org` the destination; admin/owner in both. The old `(org, name)` becomes a `410 PROJECT_REHOMED` tombstone, not a fork; reversible by moving back. Member path only — the platform-admin path is session-only (D20) and has no tool | | `get_org_audit_feed` | Read an org's member-visible audit feed (D19): today, projects that left the org for someone's personal org — who, when, where to. `org` names the org whose feed to read (required in effect). Each re-home entry carries a one-line `summary`; the operator's `reason` is redacted; page with `nextCursor` (legacy alias `next_cursor`; both opaque and identical), `hasMore` (legacy alias `has_more`) (limit 1–100) | ### Log Tools — the project's second history (ulu log) | Tool | Description | |------|-------------| | `get_project_log` | One page of the project log: `run` (what was examined), `decision` (what was decided, with reasons) and `regression` (what a run re-detected) events interleaved newest first; keyset-paged — pass `nextCursor` back as `cursor`. Optional `format: "actionable"` negotiates `projectLog/actionable-v1` through the SDK and returns full issue `fingerprint` identities with optional `displayFingerprint` presentation prefixes; omission retains legacy 12-character fingerprints. Filters: `since`/`until`, `limit` 1–500, `kind[]`, `workflow_type` (runs only), `agent`, `include_archived`. Never collapsed. Read it right: `reason: null` = no reason recorded (the ledger's silence, not a person's); `source: null` = unattributed, never "human"; a `regression` came back via a **run** (`viaRunNumber`), a `resolved → open` decision with no run was *reopened by decision*; a run's `counts: null` = saved before counts were recorded | | `get_log_stat` | The rollup — examined / found / decided / cameBack / activity — for `project` when given, else for the org the call resolves to (`org`, else the workspace default, else your personal org — looked up, never guessed). Two frames on two clocks: `decided` is the *current* status of the window's findings (sums to `found.issues`; `completed` is what the CLI prints as "fixed"), `activity` is what changed in the window by ledger time (`byStatus.open` = reopens). The org rollup adds `projects[]` (capped at 100, `hasMoreProjects`) and `computedAt` — it is cached 60 s server-side | Both read through `@uluops/ops-sdk` ≥ 6.5.0 and echo the SDK's parsed shapes (camelCase). Inputs are snake_case as on every tool here. This release also moves the server's `.uluops.json` reader to the 6.5.0 allowlist (`org`, `project`, `$schema`): a checkout whose workspace file carries `project` (read by `ulu log`) no longer makes this server refuse every call in that tree. ### Run Tools (P2) | Tool | Description | |------|-------------| | `get_run` | Get a run by UUID | | `list_runs` | List runs for a project | | `get_latest_run` | Get the latest run for a project; `{ run: null, message }` when it has none yet | | `delete_run` | Permanently delete a run (`confirm: true` required — false or missing is refused by the schema, nothing is sent) | ### Issue Tools (P2) | Tool | Description | |------|-------------| | `get_issue_by_fingerprint` | Get an issue by its SHA-256 fingerprint | | `update_issue_by_fingerprint` | Update an issue status by its fingerprint | | `restore_issue` | Restore a soft-deleted issue | | `soft_delete_issue` | Soft delete an issue (can be restored later) | | `undo_issue_status` | Undo the last status change on an issue | ### Taxonomy Tools (P2) | Tool | Description | |------|-------------| | `get_taxonomy` | Get the failure taxonomy schema (domains, modes, severities) | | `get_full_taxonomy_analytics` | Get full taxonomy analytics with distribution by domain | | `get_burndown` | Get taxonomy burndown with time series and trend analysis per failure domain | | `get_velocity` | Get velocity metrics per failure mode with sparkline data and trend reliability | | `get_discovery` | Get discovery timeline showing new vs recurring issues over time | | `get_agent_matrix` | Get agent-taxonomy coverage matrix with blind spot detection. `minIssues` filters rows by total qualifying issues per agent (default 5). `effectiveMinIssues` and `eligibility` explain the applied threshold and excluded rows. Single-point/overlap analysis uses canonical modes before threshold filtering; non-canonical codes are returned separately as `shadowModes` | ### Analysis Tools (P2) | Tool | Description | |------|-------------| | `get_run_analysis` | Structured analysis records for a single run (decision, scoring, findings) | | `get_project_analysis` | Aggregated analysis records across all runs in a project | | `query_analysis_records` | Query analysis records by type, classification, severity | | `get_agent_runs_analysis` | Per-run analysis records grouped by agent | ## Available Resources MCP resources provide read-only access to validation data via the `validation://` URI scheme. | Resource | URI | Description | |----------|-----|-------------| | Projects | `validation://projects` | List all tracked projects | | Project Summary | `validation://projects/{project}` | Routes to the `get_project_summary` tool — any project name resolves, returning a ready-made call for it (no project data) | | Taxonomy | `validation://taxonomy` | Failure taxonomy schema for classifying issues | ### Resource Usage ```text // List all projects (returns JSON array of project names) read_resource("validation://projects") // Get the failure taxonomy schema (domains, modes, severity codes) read_resource("validation://taxonomy") ``` A successful read returns two `contents[]` entries: `[0]` is the JSON payload, `[1]` is the same untrusted-content notice every tool result ends with (`text/plain`). Resources carry no `org` argument and always read the **personal** org; each read is logged as `resources/read org=personal` beside the tool calls. (Both since 0.21.1 — before that the resource path was outside the org seam entirely: no notice, no record.) **Note:** For project-specific data, use the `get_project_summary` tool. `validation://projects/` resolves for any name but only returns the call to make — it deliberately serves no project data, because resources carry no `org` argument and would read outside the org allowlist the tools enforce. (Before 0.21.1 only the literal `{project}` placeholder resolved; a real name returned a bare `-32602 not found`.) ## Security - **Credentials never leave in error text.** Every `ulr_…` key of the shape the server accepts at boot (`^ulr_[A-Za-z0-9_-]{16,}$`), and bearer tokens / `Authorization` headers, are redacted from **every** error channel — the tool result, resource reads, and the `[mcp-tool-error]` stderr line — and every occurrence in a message, not just the first. (Complete since 0.21.1; earlier versions matched a narrower key shape and redacted only the first match.) - **Destructive calls refuse before sending.** `delete_run` requires `confirm: true` as a literal — `false` or a missing value is rejected by the schema and nothing reaches the API. The confirmation is supplied by the model making the call, so on its own it guards against mistakes, not against a model that has been instructed to delete — which is what the next two controls are for. - **Destructive tools: on by default, switchable off; five ask a person in Claude Code.** **The default protects less than this heading might suggest — read the whole entry.** - *The switch.* Twelve tools are gated: `delete_project`, `soft_delete_project`, `delete_run`, `merge_projects`, `rehome_project`, `archive_runs`, `bulk_update_status`, `update_status`, `update_run`, `update_profile`, `merge_issues`, `soft_delete_issue`. They are **enabled unless you set** `ULUOPS_ALLOW_DESTRUCTIVE=false` in the registration — an environment value the model has no way to set. Each refusal is logged as a warning. Disabling them also refuses pipelines that use `update_status`, `bulk_update_status`, `update_run` or `archive_runs`. - *What the switch does not cover.* It gates those twelve names, not every way to change data. `save_run` (which can reopen issues), `edit_issue`, `update_project` (rename), `update_issue_by_fingerprint`, `undo_issue_status` and `restore_*` stay available when disabled, one record per call. They are labelled `destructiveHint: true`; only `create_issue`, `create_project` and `add_issue_note`, which only add, are labelled non-destructive. - *The prompt.* `delete_project`, `delete_run`, `merge_projects`, `rehome_project` and `update_profile` carry `_meta["anthropic/requiresUserInteraction"]`. Per Claude Code's documentation that makes it ask you before each call in every permission mode, and refuse the call in `dontAsk`/headless runs — which this server cannot see: a host-side denial never reaches it, so if a pipeline "can't delete", look at the host's output, not this server's log. Observed 2026-09-24 against this release on Claude Code 2.1.280, headless: `delete_run` was denied in `dontAsk` mode despite an explicit allow rule, and in `bypassPermissions` mode, while `archive_runs` and `get_project` under the same settings reached the server. The interactive prompt in default mode is documented, not yet observed here. Claude Code's denial text tells the model it *may* try other tools for the same goal; these five tools' descriptions tell it not to. Other MCP hosts may ignore the key. The five are chosen as the operations this release treats as irreversible; whether the other seven are reversible at the API is not yet verified. - *Unset means exposed.* With the env unset, the seven gated tools that do not prompt — including `bulk_update_status` and `update_status` — are exactly as reachable by an injected instruction as in 0.21.4. - **Tracker content is untrusted.** Every successful tool result and resource read ends with a notice that the data was written by tracker users. Project names, issue text and descriptions are data, never instructions — and an `org` is never taken from them (see [Which org a call lands in](#which-org-a-call-lands-in)). - **Org scope is explicit.** `ULUOPS_ORG_ALLOW` restricts which orgs a call may target, and every call logs the org it *requested* (explicit, workspace, env or omitted). The log does not prove where a write landed — the result's `effectiveContext` block is the server's own answer to that. ## Development ```bash # Install dependencies npm install # Development mode with watch npm run dev # Run tests npm test # Type checking npm run typecheck # Linting npm run lint # Build for production npm run build ``` ### Exact report replay checks New submissions can explicitly select report comparison: ```json {"project":"example","workflow_type":"explore","idempotency_key":"submission-1","idempotency_contract":"report-v2","agents":[{"name":"explorer","decision":"TRACED"}],"recommendations":[],"raw_markdown":"Report bytes"} ``` The SDK checks server capability support before writing. Omission retains `legacy-v1`, which excludes report text; response `idempotency.excludedFields` makes that explicit. V2 compares exact bytes, including newlines; omitted/null reports are equivalent. The same key cannot change contracts or accepted payload. Refusals are `not_applied`; read the original run before choosing a new key for an intentional new submission. The accepted hash remains unchanged by later token enrichment. Supply an explicit key for harness retries (the tool still defaults the timestamp on each call). ### Analysis types and saved history Pass `agent_type` on analysis records and summaries only for agents that are not in the registry. A registered agent's type comes from its exact linked version, and a different declaration is overridden and marked `conflict`. The API retains type provenance; query filters also accept `unknown`. Saved occurrence classifications describe the finding at that run, even after its current issue status changes. Legacy history without those facts remains `unknown`. These contracts require Tracker API 2.1.1 or newer. The agent matrix distinguishes the complete per-agent population from pairs eligible for the requested `minIssues` threshold. ## License MIT ## Quality metric contracts (F04) Analytics and lifecycle descriptions distinguish `scoreThresholdPassRate` (legacy alias `passRate`) from gate outcomes. Read denominator, threshold and raw-unscaled score metadata before interpreting the percentage. Requires the F04-tolerant Ops SDK to retain metadata from upgraded producers. Weekly burndown returns the final available stock snapshot per UTC ISO week, with clipped interval boundaries and partial flags. The point date is the snapshot date; daily counts are never summed. `asOf` identifies the observation time and the current day is partial. Trend diagnostics remain based on daily samples, so average daily change keeps issues/day units. Metadata requires the upgraded API; older servers remain readable without it. ### F12 pricing coverage (opt-in) `coverage-v1` separates supported model pricing from unpriced observations. `pricedCost` is in microdollars and is null when no snapshots have a supported model; a supported zero-token snapshot costs zero. `pricedCostDisplay` preserves the existing dollar formatting. Estimates never contribute to priced totals. Run coverage partitions distinct runs: a run is fully priced only if every snapshot in that population has a supported model. A mixed run counts once as unpriced but retains its priced snapshots' cost. Snapshot and token counts also partition independently; coverage values are ratios, or null for an empty denominator. Group run counts can overlap across groups. The configured table supports `haiku`, `sonnet`, `opus`, `claude-3-`, and their dated IDs (`20240307` for Haiku, `20240229` for Sonnet/Opus). Whitespace and case normalize for lookup; output retains the original identity. Other IDs, including newer and provider-prefixed models, remain unpriced pending an explicit rate-table review. The table was last verified on 2025-03-27; its source/date are returned. This is model-rate coverage, not verified billing or complete token telemetry: null token fields retain legacy zero handling. Legacy cost defaults remain unchanged. Removal requires a separately authorized next major and at least 90 days' notice after supported consumers are available. ```json {"metric":"cost_analysis","pricing_contract":"coverage-v1","org":"ulu-labs"} ``` Use the explicit selector for cost coverage. Add `estimate_model:"sonnet"` only when an estimate is desired. The SDK negotiates server support and refuses an unsupported contract instead of silently returning legacy Sonnet fallback. ## Discovery pages (F15 / page-v1) Authenticated `GET /api/v1/capabilities` advertises `contracts.discovery: ["page-v1"]`. F07 agent pages additionally require `contracts.agentDiscovery: ["recorded-v1"]`, so an older API that only supports F15 pages is not mistaken for an F07 producer. Select `format=page` on the endpoints below to receive `{data,total,count,limit,offset,hasMore}`. `count` is the number on this page; `total` counts the same authorized and filtered dataset. `hasMore` is `offset + data.length < total`. SDK/MCP omit the redundant `count`. Defaults are limit 50 and offset 0; limit must be an integer 1–100 and offset an integer 0–9007199254740991. Malformed, fractional, negative or unsafe values are rejected rather than truncated. An offset at or past the total returns an empty page and `hasMore=false`. Exhaustive offset traversal is guaranteed for a fixed dataset; concurrent writes are not snapshot-isolated. | Endpoint | Additional controls | Default order | |---|---|---| | `/projects` | `search`, `sortBy=name/createdAt`, `sortOrder=asc/desc` | name asc, id asc | | `/projects/:id/issues` | `workflowType`, `classified`, `sortBy=createdAt/priority`, `sortOrder` | priority asc, createdAt desc, id asc | | `/issues/search` | `offset`, `sortBy=createdAt/priority`, `sortOrder` | relevance desc, timesSeen desc, id asc; wildcard uses timesSeen desc, updatedAt desc, id asc | | `/runs/project/:projectId` | `showArchived`, `sortBy=runNumber`, `sortOrder` | runNumber desc, id asc | | `/analysis/records` | `project` name/UUID and `runId` UUID | createdAt asc, id asc | | `/projects/:id/analysis` | existing agent/type/decision filters | run timestamp desc, summary id asc | | `/agents/:name/runs-analysis` | existing project/decision filters | run timestamp desc, summary id asc | | `/agents/discovery` | required `format=page`; `project`, `days`, `search` | agent name asc (binary) | The F15 page endpoints above accept `fields` as a comma-separated list of public **output** names in camelCase. Search, sort and projection require `format=page`. Omitting `fields` retains the endpoint's existing projection; an empty list selects only `id`. Identity and page metadata are always retained. Unknown/private fields and unsupported sorts return validation errors. Projection follows authorization, filtering and pagination and never changes database access. `/agents/discovery` has a fixed `{name}` row shape and does not accept field projection; undeclared query controls are rejected. Explicit sorts always end with immutable id asc; when only `sortOrder` is provided it controls the endpoint's default primary key (analysis has no sort controls). Optional public fields absent from a row remain absent. Project search is a case-insensitive literal substring of `name` (the canonical project slug; there is no separate slug column). Whitespace is no filter; `%`, `_`, quotes and backslashes are literals, never query syntax. Issue `workflowType` matches an occurrence in that workflow using EXISTS, so repeated occurrences cannot duplicate rows. `classified=true` means a non-null `failureCode`; false means null. `failureMode` and all other issue filters apply identically to row and count queries. Full-text search chooses strict versus relaxed matching from the total, never from the current page. Referenced projects/runs must be visible in the authenticated org, and a selected run must belong to the selected project. Archived runs stay excluded unless `showArchived=true`; malformed booleans are rejected in page format. Existing analysis-summary archive policy is unchanged. Agent search is a case-insensitive literal substring; `%`, `_` and backslash are treated as ordinary characters. The no-filter MCP `list_agents` call collects all pages for the default 30-day window and retains the legacy `enabled:true` row marker to mean “recorded in this window,” not configured or authorized. Explicit `format=page` returns the page envelope and supports project/time/search filters and caller-controlled offsets. Issue routes intentionally support legacy global system-key reads. A new paged request with a system credential and an explicit `X-Org-Id` or `X-Org-Slug` is refused with 403 `UNSUPPORTED_SYSTEM_ORG_SCOPE`; use org-bound credentials for scoped discovery. The server never silently ignores a requested scope. Ordinary and org-bound keys use existing org authorization. ## Public projection allowlists - Projects: `id,name,domain,ownerId,orgId,createdAt,updatedAt`. - Issues: `id,projectId,authorId,fingerprint,semanticFingerprint,title,status,priority,severity,failureCode,failureDomain,failureMode,failureSeverityCode,category,agent,type,filePath,lineNumber,timesSeen,firstSeenRunId,lastSeenRunId,resolvedAt,resolutionRunId,mergedFromProjectId,mergedIntoIssueId,description,effectiveDescription,createdAt,updatedAt`. - Runs: `id,projectId,runNumber,workflowType,timestamp,allGatesPassed,averageScore,archivedAt,archiveReason,createdAt,totalRecommendations,criticalCount,highCount,suggestedCount,backlogCount,agentScores`. - Analysis records: `id,runId,agentName,agentType,agentTypeSource,agentTypeDefinitionId,agentTypeDefinitionVersion,recordType,recordId,title,classification,severity,recordData,createdAt`. - Analysis summaries: `id,runId,agentName,agentType,agentTypeSource,agentTypeDefinitionId,agentTypeDefinitionVersion,decision,score,decisionVocabulary,systemMetrics,categoryScores,epistemicAssessment,auditImplications,explorationMaps,createdAt`. - Agent analysis: summary fields plus `runNumber,runTimestamp,workflowType,snapshotScore`. ## Compatibility and rollout Legacy endpoint envelopes, SDK list methods, and MCP calls without `format` retain their existing shapes. `search_issues` retains its array default and 20-result default; opted-in pages default to 50. The SDK's additive `client.discovery.*` methods negotiate on the current scoped client for every operation and raise `UnsupportedContractError` if page-v1 is unavailable. They do not retry using legacy semantics or cache capabilities across orgs. MCP new search/sort/projection/archive/scope inputs require `format=page`. `query_issues` also exposes offset and repairs workflow/classified forwarding on its existing path. This release explicitly uses offsets instead of adding cursors to every list. Project-log and org-audit cursors remain opaque and unchanged. F07 uses the same page-v1 capability for recorded-agent discovery. Rollout: publish the tolerant SDK, deploy the capability-producing API, then publish/select the new MCP. Keep previous packages available; client rollback uses the old methods/tools. API rollback makes new clients refuse unsupported pages truthfully. No schema migration or legacy default flip is required. ```json {"tool":"list_projects","arguments":{"format":"page","search":"billing","sort_by":"createdAt","sort_order":"desc","fields":["name"],"limit":50,"offset":0}} ``` `list_projects`, `query_issues`, `search_issues`, `list_runs`, `query_analysis_records`, `get_project_analysis` and `get_agent_runs_analysis` all accept `format: "page"` and `fields`. MCP inputs use snake_case (`sort_by`, `sort_order`, `include_archived`, `workflow_type`, `run_id`); field names and sort values are public camelCase output names. Pass a returned `id` directly into the corresponding read tool. ### Analytics pages `get_analytics` accepts `format: "page"` for `agent_performance`, `resolution_rates`, `file_hotspots`, `trend_summary`, `taxonomy_distribution` and `cross_project_patterns`. The result is `{data,total,limit,offset,hasMore,implemented,reason?}`. Defaults are limit50/offset0, maximum limit100; total covers the authorized filtered population. The SDK negotiates `analytics/page-v1`; unsupported APIs return an error without fallback. Pages use stable ordering on fixed data; concurrent writes are not snapshot-isolated. Without `format`, agent performance and taxonomy return `{data,total}`; the other four list metrics return arrays. Legacy limit defaults to20. Regression and cost analysis retain their domain objects and reject page selection. Cross-project patterns remain a placeholder: legacy `[]`, or a page with `implemented:false` and a reason. Cost's `pricing_contract: "coverage-v1"` remains a separate object contract. Authorized `get_run`, `get_latest_run`, and `get_run_details` reads may include actor-scoped `editCapabilities`, using camelCase API request keys. Read-scoped API keys receive `canUpdate: false` with `denialReason: INSUFFICIENT_SCOPE`, even for publisher/owner actors. Absence means unknown on older producers; writes recheck authorization and policy. Recorded zero scores and false gate results cannot be changed or cleared; identical values may be echoed. Archive metadata is UUID-only (`archivedAt`, `archivedReason`) in the API/SDK and is not exposed by `update_run`. Agent enrichment follows existing agent update restrictions. `recordWriteMode` is a conditional option requiring analysis records; it is not an independently mutable run field. `update_run` does not accept immutable `workflow_type` or `timestamp` inputs. Issue reads can carry optional nullable `effectiveDescription`, the remediation narrative, alongside the existing occurrence `description`. Actionable log support requires the F19 Ops SDK release and an API advertising `actionable-v1`; unsupported selectors do not fall back.