npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uluops/ops-mcp

v0.35.0

Published

MCP server for the UluOps tracker API — runs, findings, issues, analytics, the project log and org management

Readme

UluOps · The operations layer for agentic work


@uluops/ops-mcp

npm version License: MIT Node.js Version TypeScript 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

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):

npx -y @uluops/ops-mcp

Option B — global install:

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/[email protected] in the registration, or npm install -g @uluops/[email protected]. 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:

[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:

{
  "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 | 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)

{
  "mcpServers": {
    "uluops-ops": {
      "command": "npx",
      "args": ["-y", "@uluops/ops-mcp"],
      "env": {
        "ULUOPS_API_KEY": "ulr_your-api-key-here"
      }
    }
  }
}

Option 2: Globally installed binary

{
  "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/):

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

[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:

// 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: {} }]
})
// 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: "<run uuid from list_runs or save_run>" })   // 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:

{
  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):

{
  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

// 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 <uri> 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/<name> 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).
  • 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

# 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:

{"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-<tier>, 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.

{"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.

{"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.