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

@complyflow/mcp-server

v0.2.0

Published

Unified ComplyFlow MCP server — composite workflow tools over API v2

Readme

ComplyFlow MCP Server

Unified MCP server exposing ComplyFlow compliance data as workflow-oriented tools for AI consumers (Claude Desktop, SAM).

Quick Start

cd mcp-server
npm install
npm test          # run unit tests (incl. contract-conformance)
npm run typecheck  # type-check only

Tools

The server registers 116 tools. describe_available_data is the runtime source of truth — call it to discover domains and pick a tool. The catalogue below is grouped by domain; composite tools stitch several API calls into one answer.

Discovery & identity

| Tool | Description | |------|-------------| | whoami | Identify the authenticated user (staff_id, name, org, abilities) from the API token | | describe_available_data | Discover what data domains exist and which tool to use — call first when unsure |

Flagship overviews (composite)

| Tool | Description | |------|-------------| | get_org_compliance_dashboard | "How is our org doing right now?" — org-wide compliance answer | | get_site_compliance_snapshot | "How is site X really doing?" — single-site compliance answer | | get_site_overview | Site summary: open incidents, overdue actions, contractor compliance, workforce | | get_recent_safety_activity | Week-in-review safety digest | | get_site_attendance | Who's on site right now (or on a given day) |

Sites

| Tool | Description | |------|-------------| | list_sites | List sites/projects in the organisation | | compare_sites | Compare 2–5 sites side by side | | get_site_config | Test and training-course requirements configured for a site | | list_site_staff | Internal staff (project_staff) assigned to a site | | list_site_contractors | Contractors assigned to a site | | list_site_employees | Workers (contractor employees) assigned to a site | | list_site_documents | Documents required at a site | | list_site_documents_all | Site documents across the whole org (no site_id) | | list_site_documents_not_required | NOT-REQUIRED documents at a site | | list_site_documents_not_required_all | NOT-REQUIRED site documents across the whole org | | create_site | Create a site/project (name + date_start required) | | update_site | Update a site — partial; pass null to clear contact/parent/end date | | set_site_contractors | Assign contractors (organisation_contractor ids) to a site, or remove them | | set_site_staff | Assign staff to a site (add-only — removal not supported yet) | | upload_site_document | Upload a document to a site against a site document-category |

Incidents

| Tool | Description | |------|-------------| | investigate_incidents | Search/filter incidents with a corrective-action summary per incident | | get_incident_detail | Full detail for one incident, including corrective actions | | get_incident_form_overview | Incident-report form: schema + sample real answers (composite) | | list_incident_questions | Configured incident-report question schema for the org |

Corrective actions

| Tool | Description | |------|-------------| | list_corrective_actions | Canonical tool for any "actions / recommendations / overdue / CARs" query, across incidents and inspections |

Contractors & workers

| Tool | Description | |------|-------------| | list_contractors | List contractors with compliance status | | get_contractor | Get a single contractor by id | | get_contractor_360 | Health-check view of one contractor in a single call (composite) | | get_contractor_requirements | Documents grouped by status + contractor-category catalogue (composite) | | list_contractor_documents | Documents on a contractor | | list_contractor_categories | The org's contractor_category catalogue | | list_employees | List workers (contractor employees) org-wide | | get_employee | Get a single worker by id | | get_employee_360 | Full health check for one worker (composite) | | list_employee_documents | Documents for one worker | | list_employee_categories | The org's employee_category catalogue | | analyse_compliance | Document-compliance breakdown for employees or staff | | get_workforce_status | Contractors, their employees, and training status |

Staff

| Tool | Description | |------|-------------| | list_staff | List internal staff org-wide | | get_staff | Get a single staff member by id | | list_staff_categories | The org's staff_category catalogue |

Inspections / audits

| Tool | Description | |------|-------------| | list_inspections | List inspections/audits (metadata only) | | get_inspection_overview | Single inspection drill-in (composite) | | list_inspection_actions | Low-level access to audit_item rows (every checklist row) | | list_inspection_templates | The org's inspection-template catalogue | | get_inspection_template | One template with its full checklist embedded | | get_inspection_template_overview | Template + recent inspections that used it (composite) |

Forms & permits

| Tool | Description | |------|-------------| | list_forms | List forms (permit_type catalog) with section/question/response counts | | get_form | A form's full schema — sections and questions | | get_form_overview | Explain a form: structure + recent activity (composite) | | list_form_responses | Submitted responses (permits) for a form | | get_form_response | One form response with answers, files, and latest AI review per question | | list_permits | List permits (form responses) across the org | | get_permit_overview | One permit with the form schema beside the answers (composite) |

Risk register

| Tool | Description | |------|-------------| | list_risks | List risk register entries | | get_risk | Get a single risk-register entry by id | | get_risk_overview | One entry with its actions + assessments in one call (composite; does not include controls — no read endpoint exists) | | list_risk_activities | Activities (control-actions + assessments) recorded against one entry | | get_risk_schema | Hazard categories + risk matrices with every matrix cell (incl. cell id) | | create_risk | Create a risk and attach it to one or more sites (sites required, min 1) | | update_risk | Update ONE site's status, residual rating and controls | | update_risk_definition | Update the shared risk — affects every site it is on | | add_risk_controls | Add controls to one site-risk | | attach_risk_to_site | Attach an existing risk to another site |

Every risk tool's id is a risk-register entry id (project_risk — a risk as it applies to one site), never the shared parent risk_id that responses also carry.

Hazards

| Tool | Description | |------|-------------| | list_hazards | This org's hazards — the id column is the id_hazard create_risk requires | | list_hazard_categories | The full global taxonomy, including categories unused by this org (hazard_count: 0 means available, not unavailable) | | create_hazard | Add a hazard to an existing category (org-scoped) | | create_hazard_category | Add a category — GLOBAL, visible to every organisation, org-owner/CF-admin only, no undo via this API |

Plant & equipment

| Tool | Description | |------|-------------| | list_plant | List plant/equipment registered to the org | | list_plant_categories | The org's plant_category catalogue |

Training

| Tool | Description | |------|-------------| | list_training_records | Training completion records for employees and staff | | list_training_courses | The org's training course catalogue | | list_training_sessions | The org's offline (in-person) training sessions | | get_training_gap_analysis | Training gap analysis (composite) | | assign_training_course | Assign online course(s) to a staff member or worker, or unassign them | | record_training_completion | Record an online-training completion (PASSED result) | | expire_training_record | Expire a PASSED record so the person must retake the course | | record_offline_attendance | Mark a person as attended on an offline training session |

Sign-ins

| Tool | Description | |------|-------------| | list_sign_ins | List site sign-in/out events |

Install (customers — published package)

The server is published to npm as @complyflow/mcp-server and runs on the customer's machine over stdio. Requires Node.js 20+.

Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json) or your project's .mcp.json / ~/.claude/settings.json:

{
  "mcpServers": {
    "complyflow": {
      "command": "npx",
      "args": ["-y", "@complyflow/[email protected]"],
      "env": {
        "COMPLYFLOW_API_URL": "https://test.complyflow.com.au/app/public/index.php/api/v2",
        "COMPLYFLOW_API_TOKEN": "cf_pat_YOUR_TOKEN_HERE"
      }
    }
  }
}
  • Keep -y and pin the version. -y auto-confirms the install so the server doesn't hang on a prompt when the MCP client spawns it non-interactively. The form to avoid is the unversioned npx -y @complyflow/mcp-server, which floats to the latest release and would ship every publish to every machine instantly — always pin (@0.2.0).
  • COMPLYFLOW_API_URL selects the environment (test vs prod); a token only authenticates against its own environment, so a mismatch fails safely. The canonical base URL is tracked in CFCON-21322.
  • COMPLYFLOW_API_TOKEN is a scoped ComplyFlow API token (cf_pat_…); it needs abilities matching the tools you use (e.g. sites:read, incidents:read). The literal-token form above is the most reliable. If your client supports environment-variable substitution you can instead write "COMPLYFLOW_API_TOKEN": "${COMPLYFLOW_API_TOKEN}" to keep the secret out of the file — but the variable must exist in the environment the MCP client is launched from. A line in ~/.zshrc is not read by GUI launches or already-open terminals (set it in ~/.zshenv, or via launchctl setenv on macOS, then fully restart the client); otherwise the token arrives empty.

Note: On the test environment the Laravel app is served under /app/public/index.php/, so the API base URL includes that prefix (as shown above).

First-run cold start. The first time an MCP client spawns the server, npx downloads the package, which can exceed the client's start-up timeout and surface as "Failed to connect". This is a one-off — restart the client and it connects using the now-cached package.

Run from a clone without publishing:

{
  "mcpServers": {
    "complyflow": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"],
      "cwd": "/absolute/path/to/sam/mcp-server",
      "env": {
        "COMPLYFLOW_API_URL": "https://test.complyflow.com.au/app/public/index.php/api/v2",
        "COMPLYFLOW_API_TOKEN": "cf_pat_YOUR_TOKEN_HERE"
      }
    }
  }
}

Verify your setup (--check)

Before wiring the server into Claude, confirm your token + URL work with one command:

COMPLYFLOW_API_URL="https://test.complyflow.com.au/app/public/index.php/api/v2" \
COMPLYFLOW_API_TOKEN="cf_pat_YOUR_TOKEN_HERE" \
npx -y @complyflow/[email protected] --check

On success it prints the environment and the authenticated identity, and exits 0:

✔ complyflow-mcp v0.2.0 — connection OK

**API URL:** https://test.complyflow.com.au/app/public/index.php/api/v2
**Environment:** test _(reported by the server)_
**Token:** cf_pat_…9876

## Current User
**Name:** Jane Staff
**Organisation:** Acme Pty Ltd (id 42)
**Allowed sites:** 12
**Abilities:**
- sites:read
- incidents:read

On failure it prints the specific cause + remedy and exits non-zero (CI/support friendly). The token value is never printed — only a redacted cf_pat_…<last4> indicator. --doctor is an alias for --check.

How --check tells a wrong URL from a bad token (CFCON-22073)

--check probes the unauthenticated GET /api/v2/health before the authenticated GET /ping. Because /health needs no token, a response from it proves the base URL is right and the API is served there — which is what lets the 401 remedy stop hedging:

✖ Authentication failed (401).
  The Platform API IS served at this URL (/health responded, environment "test").
  So the base URL is correct — the TOKEN is the problem: invalid, expired,
  or created on a different environment than "test".

versus a host that genuinely has no API v2:

✖ Endpoint not found (404).
  Neither /health nor /ping is served here, so this host does not serve
  the Platform API v2 at all (wrong host, or v2 not deployed there).

The **Environment:** line is annotated with where the value came from, in this precedence:

| Source | Annotation | Notes | |---|---|---| | MCP_ENV | _(from MCP_ENV)_ | Explicit operator label; highest precedence | | GET /health | _(reported by the server)_ | Authoritative — the server's own config | | Hostname | _(guessed from the hostname)_ | Fallback when /health is unavailable |

When the hostname and the server disagree, --check warns — a host named production that answers test is a real misconfiguration, and it is invisible without the server's own answer. Each warning prints as a single line:

⚠ The URL looks like "test" but the server reports "isti". Confirm COMPLYFLOW_API_URL points at the environment you intend.

An explicit MCP_ENV that disagrees with the server gets its own warning. MCP_ENV still wins as the displayed label — it is an operator override — but the server's value is the authoritative fact:

⚠ MCP_ENV is set to "staging" but the server reports "test". MCP_ENV is a label only — the server's value is authoritative.

The server-reported environment is sanitised before it is printed. It is the one field of the health response that reaches your terminal, and it arrives from a host --check has not yet established as trustworthy — that is the whole reason you are running the command. Non-[A-Za-z0-9_.-] characters are stripped and the label is capped at 32 characters, so a hostile or misconfigured host cannot repaint or spoof the output with ANSI escapes or a multi-kilobyte string. A label left empty by that filter is treated as no label at all, falling back to the hostname guess.

A /health 404 is never fatal on its own. Every ComplyFlow deployment predating CFCON-22073 serves the full v2 API but has no /health route, so --check falls back to its previous wording there rather than wrongly reporting the API as absent. Only /health and /ping both 404ing concludes that v2 is not served.

Throughout this section, /health means the Platform API's GET /api/v2/health. The MCP server also serves its own unauthenticated GET /health in HTTP mode (an ALB liveness check, reporting name: "complyflow-mcp" — see HTTP mode). They are different endpoints on different services; the differing key (api vs name) is what tells them apart.

The server also validates at connect time. --check is the explicit pre-flight, but the same GET /ping validation now runs automatically whenever an MCP client starts the server, so a bad token fails the connection instead of appearing "connected" with tools that 401 on first use. In stdio mode an invalid/expired token (or an unreachable API) makes the process exit non-zero, so the client shows the server as failed; in HTTP mode it makes initialize return 401 (invalid token) or 502 (API unreachable) instead of opening a session. Run --check first to get the pretty diagnosis; the connect-time gate is the safety net if you skip it.

The wording differs slightly between --check and the connect-time gate, because each formats its own output. The Symptom column below quotes --check; the connect-time equivalent is noted where it differs.

| Symptom (from --check) | Cause | Fix | |---------|-------|-----| | COMPLYFLOW_API_URL is not set | env var missing or empty | Set it and re-run. Note a whitespace-only value (" ") is not caught here — it passes this check and fails later as Could not reach … | | COMPLYFLOW_API_TOKEN is not set (unset, empty, or only whitespace) | env var missing, empty, or whitespace-only | Set it and re-run. A quoted " " counts as not set — it is not reported as a bad token | | Authentication failed (401). … The Platform API IS served at this URL | base URL is confirmed correct (/health answered), so the token is invalid/expired or was created on another environment | Re-issue the token in the environment named in that line — do not change the URL | | Authentication failed (401). … Confirm COMPLYFLOW_API_TOKEN matches the environment of this URL (<env>) | same, but /health did not respond successfully — no route on that build, or the endpoint errored/timed out — so the base URL could not be confirmed | Re-issue the token in the environment named in that line. At connect time the same failure reads COMPLYFLOW_API_URL points at: <env> | | ⚠ MCP_ENV is set to "<a>" but the server reports "<b>" | an explicit MCP_ENV override disagrees with the environment the server reports | Fix or drop MCP_ENV — it only relabels output, it cannot change which environment the URL actually reaches | | Authorised but forbidden (403). | token is valid but lacks an ability the server needs | Re-issue the token with the required scopes — do not change environment | | Endpoint not found (404) … this host does not serve the Platform API v2 at all | neither /health nor /ping answered — wrong host, or v2 not deployed there | Use the full base URL (test includes /app/public/index.php/api/v2), or pick a host that serves v2 | | ⚠ The URL looks like "<a>" but the server reports "<b>" | COMPLYFLOW_API_URL points at a host serving a different environment than its name suggests | Confirm the URL is the environment you intend — the server's value is the authoritative one | | Could not reach <host> | URL host unreachable / network or VPN | Check the host and your network/VPN | | A tool call returns API 401: … mid-session | token expired or was revoked after the server started; in stdio, it may also never have been inherited from the launch environment | The message carries its own remedy, and differs by transport: stdio points at COMPLYFLOW_API_TOKEN (re-run --check, then restart the MCP client); HTTP points at the Authorization header you sent (re-initialize the session) |

In HTTP mode (MCP_TRANSPORT=http) auth is per request, so --check validates the stdio credentials only (there is no server-side env token). Each caller's bearer is instead validated at initialize (see the connect-time note above), and the unauthenticated GET /health probe still confirms a running instance + its base URL.

Transport Modes

| Mode | Env var | Use case | |------|---------|----------| | stdio (default) | MCP_TRANSPORT=stdio or omit | Claude Desktop, local dev | | Streamable HTTP | MCP_TRANSPORT=http | Hosted/remote (Claude in Slack, Copilot, SAM agent); TLS at ALB |

stdio vs HTTP auth (important)

  • stdio is single-user: it reads one COMPLYFLOW_API_TOKEN from the environment at startup and fails fast if it is missing.
  • HTTP is multi-tenant and authenticates per request. It does not read COMPLYFLOW_API_TOKEN. Each caller sends its own Authorization: Bearer cf_pat_… (or cf_sat_…); the server builds a per-session client bound to that token, so one hosted instance serves many organisations with no cross-tenant bleed. Setting a server-side token would be ignored.

HTTP environment variables

| Var | Required | Default | Purpose | |-----|----------|---------|---------| | MCP_TRANSPORT | yes | stdio | Set to http | | COMPLYFLOW_API_URL | yes | — | API v2 base URL for this environment | | MCP_PORT | no | 3100 | Listen port (ALB target group forwards here) | | MCP_ENV | no | unknown | Environment label reported on /health | | MCP_ALLOWED_HOSTS | recommended | — | Comma-separated Host allowlist; enables DNS-rebinding protection when set. Leave unset only behind a trusted network — the server logs a warning at boot when it is off. | | MCP_SESSION_TTL_MS | no | 1800000 | Idle session reaped after this many ms | | MCP_MAX_SESSIONS | no | 1000 | Cap on concurrent sessions (further initialize → 503) | | MCP_MAX_BODY_BYTES | no | 1048576 | Max request body before 413 |

HTTP mode exposes an unauthenticated GET /health returning { ok, name, version, environment } for ALB health checks and base-URL probing. A missing/invalid bearer on any other route returns 401; an unknown/expired session id returns 404 so clients re-initialize.

Testing

npm test                                         # full unit suite (incl. contract-conformance)
npx vitest run tests/contract-conformance.test.ts # offline: mock fixtures vs openapi.yaml

# live: real API responses vs openapi.yaml (needs a base URL + PAT)
COMPLYFLOW_API_URL="http://localhost:8080/app/public/index.php/api/v2" \
COMPLYFLOW_API_TOKEN="cf_pat_..." \
npm run conformance:live

Two layers keep api-contract/openapi.yaml, this client, and the live API in agreement — the offline test (mock ↔ contract, runs on every PR) and the live sweep (API ↔ contract, run manually for now; not yet wired into CI). See contract-conformance-testing.md for how it works, how to triage a failure, and how to add coverage for a new endpoint.

Releasing (maintainers)

The package publishes to public npm via a manual Bitbucket pipeline (bitbucket-pipelines.yml → custom: publish-mcp-server). It ships compiled dist/ only — consumers run on node, never tsx.

  1. Bump version in package.json (semver). npm versions are immutable — every publish needs a new number.
  2. Commit + merge to the release branch.
  3. Bitbucket → Pipelines → Run pipeline → pick the branch → custom: publish-mcp-server. Set NPM_DIST_TAG:
    • latest — prod-stable (what a pinned npx @complyflow/mcp-server@<v> installs).
    • next — preview/Test (0.2.0-next.0 style); does not move @latest.
  4. The pipeline runs build → typecheck → test → npm publish --access public --tag <dist-tag>.
  5. Verify: npx -y @complyflow/mcp-server@<version> boots over stdio and lists tools.

Version vs dist-tag — don't confuse them. The version is "version" in package.json (what you publish); the dist-tag (NPM_DIST_TAG) is a moving label (latest / next). To cut a patch like 0.1.1: bump package.json to 0.1.1 and run with NPM_DIST_TAG = latest (the default) — that publishes 0.1.1 and moves @latest → 0.1.1 automatically. Never put a version number in NPM_DIST_TAG: npm rejects a dist-tag that is a valid semver (Tag name … must not be a valid SemVer range). Use next only for prereleases (-next.N), so @latest never points at an unstable build.

| Want | package.json version | NPM_DIST_TAG | |---|---|---| | Stable release (becomes @latest) | e.g. 0.1.1, 0.2.0 | latest | | Test/preview (leaves @latest alone) | e.g. 0.2.0-next.0 | next |

Publish security:

  • NPM_TOKEN is a granular npm automation token scoped to publish @complyflow only, stored as a secured Bitbucket deployment variable (npm-publish environment) — never committed.
  • Restrict who can run the custom pipeline / approve the deployment.
  • npm provenance (--provenance) is not available on Bitbucket Pipelines (needs GitHub Actions / GitLab CI OIDC). If provenance becomes a requirement, mirror to GitHub and run the publish job there.

Retiring a tool (maintainers)

Never delete a published tool's registration. Deleting it does nothing for the users who matter.

claude.ai caches the tool set at connect time. A tool you add, remove, or re-schema does not reach an already-connected user until they manually hit "Refresh tools list" in connector settings — only descriptions and response contents update live. So a published tool signature behaves more like an API you cannot deprecate than a function you can change next sprint. Delete the registration and a cached client calling it gets "tool not found", which is an error.

An error is the wrong answer regardless of how it is produced. The tool stays in the model's list either way; if it errors, the model retries it or invents a confident wrong explanation for the failure. A successful response naming the replacement lets the model self-correct and call the right tool on its next turn.

To retire old_tool:

  1. Add an entry to RETIRED_TOOLS in src/util/retirement.ts:
    old_tool: {
      replacement: "new_tool",
      guidance: "Pass `site_id` where you passed `project_id`.",  // optional
      retiredIn: "2.1.0",
    },
  2. Delete the tool's original server.tool(...) call in src/server.ts. registerRetiredTool re-registers the same name, so the retired registration replaces the original. Forget this and the SDK throws Tool old_tool is already registered — deliberate, so the mistake cannot ship quietly. How that surfaces depends on the transport (see below).
  3. Run npm test. The guard test fails if replacement names a tool that is missing, or one that is itself retired.

Never remove a RETIRED_TOOLS entry once it has shipped. The registry only grows. A client that cached the tool set can call the old name at any point in the future, and by step 2 the original registration is long gone — so deleting the entry recreates the "tool not found" error this whole pattern exists to avoid. Retirement is permanent; there is no later cleanup pass.

Point replacement at a live tool, never at another retired one. Retired tools stay listed, so a chain (A → B, then B retired → C) is easy to create by accident and costs the model a wasted turn on every call. When you retire a tool, grep RETIRED_TOOLS for entries naming it and repoint them.

How a forgotten deletion surfaces

The throw is the same; where it lands is not.

| Transport | Symptom | |---|---| | stdio (src/index.ts, one server per process) | Uncaught at startup — the process crashes immediately and npx @complyflow/mcp-server fails loudly | | HTTP (src/http.ts, one server per session) | Every new initialize returns 500; the process stays up and /health keeps returning 200, so it looks healthy while no session can be created. Sessions established before the deploy keep working until they expire |

The HTTP signature is the dangerous one — green health check, total outage — so verify a retirement locally before deploying the hosted server.

registerRetiredTools applies the whole contract, so there is nothing else to remember:

| | Why | |---|---| | Stays registered | Un-registering is a no-op for cached clients | | No inputSchema, no outputSchema | The only form that cannot produce a validation error — see below | | readOnlyHint: true, destructiveHint: false | A stale destructive hint has hosts prompting for confirmation before a call that does nothing | | Returns a success result naming the replacement | The behaviour change is what actually retires the tool |

Why no schemas at all

A retired tool registers with no inputSchema and no outputSchema — not a loosened one, not inputSchema: {}.

The SDK validates arguments before the handler runs, and a validation failure comes back as isError: true (MCP error -32602). That is the exact error result the pattern exists to prevent, arriving where no care inside the handler can stop it. Measured on @modelcontextprotocol/sdk 1.29.0 + zod 4, across the call shapes a cached client can send:

| registration | legacy args | {} | undefined | arguments absent | |---|---|---|---|---| | original schema with a required field | error | error | error | error | | inputSchema: {} | ok | ok | error | error | | no inputSchema | ok | ok | ok | ok |

The middle row is the trap: inputSchema: {} reads as "accepts anything" and is not — it compiles to z.object({}), which rejects undefined, so a client omitting the arguments key (legal for a tool that declares no inputs) gets an error. Omitting inputSchema skips validation entirely.

outputSchema has the same door on the way out, and it is the easier mistake to make because it looks like harmless metadata. Set it while the handler returns no structuredContent — which the retirement handler never does — and the SDK answers:

isError: true
MCP error -32602: Output validation error: Tool <name> has an output schema
but no structured content was provided

So "keep the original output schema so hosts retain their type info" is a plausible-sounding change that silently reinstates the failure. Set neither schema.

Every one of these is pinned by a test in tests/util/retirement.test.ts — the rejected alternatives have their own tests (shows why inputSchema: {} is rejected, shows why outputSchema is rejected) so this table cannot rot if a future zod or SDK release changes the behaviour.

Caching cuts both ways. Adding a tool later also will not reach existing users. Get the tool set right before publishing rather than shipping small and iterating.

Architecture

See ai-data-access-architecture-review.md for the full architecture context.

This MCP server is Layer 4 in the four-layer model:

Layer 4: MCP Tools (this server)  ← Claude Desktop, SAM agent
Layer 3: API v2                   ← this server calls these endpoints
Layer 2: Eloquent ORM             ← API v2 is backed by this
Layer 1: Database                 ← accessed only via ORM