SIGNALS Documentation
API Reference

MCP server

Connect AI clients to the Signals v1 API over the Model Context Protocol — catalogue meta-tools, curated tools, plugin tools, install, auth, and LLM instructions.

Overview

Signals ships an MCP (Model Context Protocol) server that exposes the same /api/v1 REST surface external Sanctum clients use. AI clients (Claude, Claude Code, ChatGPT, and any MCP-compatible host) discover endpoints, inspect schemas, and invoke operations through authenticated tools — not a separate parallel API.

The server class is App\Mcp\Servers\SignalsApiServer (Signals API v1.0.0). HTTP transport is mounted at /mcp/api; local stdio is available via Artisan.

How coverage works

Three layers share OperationInvoker and OperationDescriber:

Layer What it exposes When to use
Meta-tools list-endpoints, describe-endpoint, execute-endpoint, get-model-schema Full catalogue coverage — any documented /api/v1 route
Curated tools 26 first-class hot-path tools: identity (whoami), search + semantic search, rentals (incl. aggregate for group-by/measure reporting reads), catalogue_items, availability, accounts, activities, dispatch/returns, invoices, payments, repairs, and stock checks Prefer these for typed args on common workflows
Plugin tools Claude-safe MCP names (e.g. plugin-signals-slots-demo-…); CLI keeps plugin:{package}:{tool} Package-specific actions declared in signals.yaml tools[]

Every curated tool result carries price_weight (cost of that call) and units_remaining (quota left on the token) alongside the payload, so an agent can budget without a separate describe-endpoint or whoami round trip.

Catalogue coverage is gated by tests/Feature/Architecture/McpCliParityTest.php: every ApiEndpointCatalogue endpoint must describe cleanly (params + typed success response) and remain structurally executable through the meta layer.

Plugin tools: see Plugin MCP / CLI tools.

Enable (cloud & self-hosted)

  1. Open Admin → Users & Security → MCP & CLI.
  2. Enable MCP server, and leave HTTP transport on for remote clients (stdio is optional for local Artisan).
  3. Mint a personal access token on API Tokens with the abilities your workflows need (catalogue-items:read, rentals:write, etc.). Prefer least privilege over *.
  4. Ensure the token owner holds the mcp.access permission (and any resource permissions the underlying actions require).
  5. Use the endpoint URL shown on the settings page (typically https://<your-install>/mcp/api).

Settings keys: mcp.enabled, mcp.http_enabled, mcp.stdio_enabled, mcp.read_only.

Read-only mode

Read-only mode (mcp.read_only, off by default) restricts the whole MCP surface to reads, regardless of what the caller's token or permissions allow:

  • Curated write tools (create-rental, change-rental-status, create-account, create-activity, submit-stock-check, complete-or-release-repair, share-document-for-approval, record-portal-response) are suppressed server-side — they never appear in tools/list, so a client cannot call what it cannot see.
  • execute-endpoint refuses any verb other than GET/HEAD.
  • Plugin-bridged write tools are suppressed and refused the same way.
  • Curated write tools refuse invocation too, as defence in depth — suppression from tools/list is the primary gate, but a tool instance reached by any other path still checks the posture before touching the API.

Every read-only refusal returns a JSON tool error carrying a machine-readable code, so an agent can tell a policy refusal from a transient failure and stop retrying:

{
  "message": "MCP is in read-only mode: POST requests are not permitted. …",
  "code": "mcp_read_only",
  "read_only": true,
  "method": "POST"
}

Tool-level refusals carry tool instead of method. The code is McpAccessGuard::READ_ONLY_CODE.

A curated tool counts as read-only when it declares the IsReadOnly annotation, so the posture advertised to clients and the posture enforced by the server are the same declaration — new write tools are gated automatically.

Read-only mode is a posture switch, not an authorisation layer: it narrows what MCP can do, it never widens it. Normal Sanctum abilities and Gate permissions still apply on top.

Session unit budget

Session unit budget (mcp.session_unit_budget, 0 = unlimited by default) caps how many weighted API units one MCP session may spend. A session is the principal's token context: the personal access token when one is presented, otherwise the resolved user. All calls on one credential share one budget, and the counter expires after api_metering.mcp_session.window_minutes (12 hours by default).

Spend is denominated in the same units as the X-Signals-Units-Charged header — each tool result already reports units_charged, and the budget is decremented by exactly that figure after the call. Nothing is metered twice, and a denial (which charges 0 units) costs the session nothing.

When the budget is spent, every metered tool call — reads and writes alike — returns a normal structured result rather than an error:

{
  "budget_exhausted": true,
  "code": "mcp_session_budget_exhausted",
  "tool": "list-rentals",
  "session_budget": { "limit": 5000, "consumed": 5000, "remaining": 0, "window_minutes": 720 },
  "units_charged": 0,
  "message": "This MCP session has spent its unit budget (5000 units). …"
}

This is deliberate: running out of budget is a stopping point the agent should report, not a failure it should retry. Under a budget, successful tool results also carry session_budget_remaining, and whoami returns the full picture under mcp.session_budget. With the default of 0 nothing is counted and no extra fields appear.

Connect

Claude (claude.ai / Claude Desktop)

Desktop and claude.ai's custom connector UI only offers OAuth — it does not accept a bearer token. The MCP server supports this via Laravel Passport (see OAuth for Desktop/claude.ai below).

Settings → Connectors → Add custom connector.

  • URL: https://<your-install>/mcp/api
  • Auth: none to configure — pasting the URL and continuing opens a sign-in page. Desktop/claude.ai register themselves as an OAuth client automatically (dynamic client registration); there's no client ID/secret to create or paste.

The user who signs in must hold mcp.access, same as the PAT path. Once connected, the session acts with that user's full permissions (see Auth below) — gate/policy checks on each invoked endpoint remain the real authorization boundary.

Claude Code

claude mcp add --transport http signals https://<your-install>/mcp/api --header "Authorization: Bearer <token>"

Replace <your-install> and <token> with your host and PAT. The same command is copyable from Admin → MCP & CLI.

ChatGPT

Settings → Connectors → Advanced / Developer mode → add an MCP server.

  • URL: https://<your-install>/mcp/api
  • Auth: Bearer personal access token

Generic MCP JSON

Many hosts accept a config entry shaped like:

{
  "mcpServers": {
    "signals": {
      "url": "https://<your-install>/mcp/api",
      "headers": {
        "Authorization": "Bearer <token>"
      }
    }
  }
}

Exact keys vary by client — prefer the client's native Streamable HTTP MCP connector settings when available (the legacy HTTP+SSE transport is deprecated by the MCP specification).

Artisan stdio (local)

When mcp.stdio_enabled is on:

php artisan mcp:start signals-api

Stdio still authenticates as the acting process user context configured by the Laravel MCP local transport; HTTP remains the primary remote path.

OAuth for Desktop/claude.ai

Claude Desktop and claude.ai's custom connector UI only supports OAuth — it runs discovery against the connector URL and fails ("couldn't reach server") if the well-known metadata endpoints aren't served. The MCP server provides these via laravel/mcp's built-in OAuth support backed by Laravel Passport:

  • GET /.well-known/oauth-protected-resource(/{path}) and GET /.well-known/oauth-authorization-server(/{path}) — discovery metadata. Public, unauthenticated; gated only by the same mcp.enabled + mcp.http_enabled settings switch as the MCP endpoint itself (settings off → 404, same as /mcp/api).
  • POST /oauth/register — dynamic client registration (RFC 7591). Desktop/claude.ai call this automatically the first time they connect; no manual client setup.
  • GET/POST /oauth/authorize, POST /oauth/token — the standard Passport authorization-code + refresh-token flow. The /oauth/authorize approval screen requires a Signals web session, so connecting opens a normal sign-in page if the browser isn't already authenticated.

All tokens are issued under the mcp:use scope (the only scope the authorization server advertises); the MCP endpoint checks for it defensively even though every dynamically-registered client is granted it automatically.

Auth path is otherwise symmetric with the PAT path: the /mcp/api route accepts both auth:sanctum (Claude Code's bearer PAT) and auth:api (Passport OAuth token) — see routes/ai.php. Both land in EnsureMcpAccess, which enforces mcp.enabled/mcp.http_enabled and mcp.access identically regardless of which guard authenticated. Tool execution differs only in how the downstream API call is authorized: a Sanctum PAT forwards its own abilities, while an OAuth session is treated as the signed-in user acting with full permissions — OperationInvoker mints a short-lived wildcard-ability ephemeral Sanctum token for the resolved User (deleted immediately after the call). Gate/policy checks inside the invoked endpoint remain the real authorization boundary either way.

Token ability scoping

Concern Guidance
MCP gate The resolved user (PAT owner or OAuth sign-in) must have mcp.access
API abilities (PAT path) execute-endpoint and curated tools use the token's Sanctum abilities — missing ability → HTTP 403
API abilities (OAuth path) Acts with the signed-in user's full permissions (wildcard ephemeral token) — gate/policy checks on the invoked endpoint are the boundary
Least privilege Scope PATs to the resources the agent may touch; rotate and revoke from API Tokens
Writes Prefer dedicated write-capable tokens only where needed; require Idempotency-Key when settings enforce it

Data egress and logging

Signals is bring-your-own-model: core Signals sends nothing to any model provider itself. When you connect an MCP client, it is that agent's provider — Anthropic, OpenAI, whoever runs the model — that receives the data, because the agent reads records through the tools and then reasons over them in its own context. The blast radius is therefore exactly what the connecting token can read, which is why ability scoping (above) is the real control. On our side, requests are logged as metadata by the LogApiRequest middleware (app/Http/Middleware/LogApiRequest.php), not as content, unless you opt in.

  • Always logged (when api_metering.logging_enabled, default on): method, path, route name, resource/operation, status code, duration, request/response byte counts, price weight and weighted units, token id, user id, first ability, IP address, and user agent.
  • Request/response bodies are NOT logged by default. They are captured only when API_METERING_CAPTURE_BODIES=true, and even then are redacted (Authorization, X-Signals-Signature, password, and the rest of config/api_metering.redaction) and truncated to API_METERING_BODY_MAX_BYTES (4 KB default).
  • Tool invocations are metered per token. MCP tool calls execute the same API operations and draw on the same per-token unit budget; each result carries price_weight, units_charged, units_remaining, and daily_remaining so an agent can budget its own session.
  • Writes made over MCP are stamped via: mcp in the audit trail, so agent activity is separable from UI and CLI activity after the fact.
  • Embeddings are the one place core Signals calls out, and they are optional and separately documented — see AI embeddings & configuration.

The short answer for early adopters: Signals itself ships no model provider and phones nothing home; connecting an agent means that agent's provider sees whatever its token can read, so scope the token and, if you want a content-level record of what was read, turn body capture on deliberately.

Meta-tools

Tool Role
list-endpoints Search/filter the catalogue (resource, method, keyword); paginated compact rows. Deprecated routes (e.g. the duplicate stock_checks.counts.* / stock_checks.items.* alias pair) are flagged so an agent doesn't pick arbitrarily between two routes with identical summaries.
describe-endpoint Full params, request/response schemas, Ransack filter/sort fields, price_weight, filter aliases (API/filter name → schema column) and enum encodings (enum_int vs enum_string) per field — resolves the "is state an int or a string here" question without a live call.
execute-endpoint Invoke any catalogue endpoint when no curated tool fits. List responses include meta.ignored_filters/meta.ignored_sorts when a supplied predicate or sort key wasn't recognised, so an unfiltered result is never mistaken for a filtered one.
get-model-schema SchemaRegistry field metadata for a model slug. Defaults to a compact projection (name, label, type, filterable, sortable, searchable, required, aliases); pass full=true for the complete field-definition payload — describe-endpoint covers the common case at a fraction of the tokens.

Resources & prompts

Beyond tools, the server registers MCP resources (read-only reference documents an agent can fetch without spending a tool call) and prompts (grounded multi-step flows):

Resource Contents
schema://models Index of every model whose field metadata is browsable; each row carries the schema://models/{model} template URI.
schema://models/{model} Field metadata for one model (filterable, sortable, searchable, aliases) — same projection as get-model-schema.
events://webhook Every domain event a webhook subscription may target, grouped by entity (resource.action names).
Prompt Flow
resolve-shortage Walk a stock shortage to resolution — detection, resolver options, sub-hire path — with dry_run and expected_version guardrails.
quote-from-enquiry Turn a free-text enquiry into a draft quotation: match the account, build the rental and items, check availability.

Resources and prompts ride the MCP transport itself (no new REST routes) and honour the same token ability scoping as the tools.

LLM instructions

Align with the server's built-in instructions (SignalsApiServer #[Instructions]):

Workflow

  1. Call whoami first when you don't already know the token's abilities, roles, rate limit, or remaining quota — no ability required, so it never 403s.
  2. Prefer curated tools (search, rentals incl. aggregate, catalogue_items, availability, accounts, activities, dispatch/returns, invoices, payments, repairs, stock checks) for hot paths — typed args, same Sanctum token abilities as the REST API.
  3. list-endpoints — search/filter the catalogue (resource, method, keyword); paginated compact rows. Rows for alias/duplicate routes carry deprecated: true plus canonical_route — prefer the canonical one.
  4. describe-endpoint — full params, request/response schemas, Ransack filter/sort fields (with per-field alias and enum-encoding metadata), price weight.
  5. execute-endpoint — invoke any catalogue endpoint when no curated tool fits.
  6. get-model-schema — SchemaRegistry field metadata for a model slug; compact by default, full=true for the complete payload.
  7. Need revenue/utilisation/outstanding rolled up rather than paged and summed client-side? Use the aggregate curated tool (or GET /api/v1/aggregate/{model}): group_by (+ bucket=month|week|day for dates), measure, fn (sum|avg|min|max|count).

Writes

  • Pass Idempotency-Key on every POST/PUT/PATCH: the API now dedupes on key+body unconditionally (identical key+body replays the original response, no duplicate row), regardless of the api.require_idempotency_key setting — that setting only controls whether the header is required, not whether it's honoured. whoami reports require_idempotency_key for the current token.
  • Only POST mutates. Every catalogue GET is read-only (readOnlyHint: true is accurate) — routes that used to mutate on GET (stock_checks.recalculate, stock_checks.revert) were converted to POST.
  • Non-2xx responses are returned as structured tool errors (status + JSON body), not exceptions. 4xx bodies include a machine-readable code (e.g. invalid_state, invalid_date_window, or a guard-specific code) alongside the human-readable message/errors — branch on code, not on message text.
  • Composite create: create-rental accepts an items array so a quote can be built (header + line items) in one call instead of 1 + N round trips.
  • Need more than a handful of rows? POST {resource}/bulk (e.g. activities/bulk, accounts/bulk) accepts an array of row payloads and returns per-row results — 201 when every row succeeds, 207 when some fail; check each row's status rather than the top-level HTTP status alone.
  • Rental transitions (change_status, convert_to_quote, convert_to_order) accept dry_run: true to run the guard pipeline and return {allowed, reason, code} without applying the transition — use it to preview feasibility before spending a mutating call.
  • Writes made through this MCP server are stamped with via: mcp in the audit trail, distinguishable from UI/CLI writes.

Reads / pagination

  • List endpoints accept cursor=<token> (start with cursor=1) as an alternative to offset page= for large sweeps; the response meta then carries next_cursor/prev_cursor instead of total/page.
  • Sparse fieldsets (fields=id,name,sku) work on index, show, and action/lifecycle endpoints — ask for only what you need on high-fan-out responses (e.g. create-account's nested membership/addresses/emails).
  • semantic-search returns embeddings_available: false (plus a message) rather than a silent empty result set when ai.enabled is off — check that flag before concluding "no matches".

Ransack filtering (list endpoints / execute-endpoint)

  • Prefer the filters object with predicates as keys (no q[…] wrappers): {"name_cont":"widget","id_eq":1,"created_at_gteq":"2026-01-01"} → q[name_cont], q[id_eq], q[created_at_gteq].
  • Curated list tools also expose typed aliases (e.g. state → q[state_eq]) plus filters for additional predicates.
  • Array query params use unbracketed names in tool schemas (types, not types[]).
  • Sort with sort=name or sort=-created_at. Use describe-endpoint / get-model-schema for filterable fields.
  • A filter key that isn't recognised, or references a non-filterable field, is silently skipped rather than erroring — but the response's meta.ignored_filters (and meta.ignored_sorts for sort) lists exactly which keys were dropped. Always check meta.ignored_filters on a list response before trusting it as filtered — an empty array means every predicate you sent was applied.

Auth

  • HTTP transport accepts a Sanctum bearer token (Claude Code) or a Passport OAuth session (Claude Desktop / claude.ai) whose resolved user holds mcp.access — see OAuth for Desktop/claude.ai.
  • execute-endpoint uses the Sanctum token's abilities on the PAT path; on the OAuth path it acts with the signed-in user's full permissions. Missing ability or gate permission yields 403 either way.

Plugins

  • Enabled plugins may register additional tools; MCP names are Claude-safe (hyphenated). CLI still uses plugin:{package}:{tool} (see PluginToolRegistry).

Error shapes

  • Validation failures: 422 with Laravel message + errors map, plus a machine-readable code on state/status/guard/window denials (e.g. invalid_state, invalid_date_window) — branch on code rather than parsing message.
  • Auth: 401 unauthenticated, 403 forbidden (ability or permission).
  • Not found: 404. Success envelopes use singular/plural resource keys as documented for each endpoint.