Model Context Protocol
NexSpace exposes a JSON-RPC 2.0 MCP server athttps://mcp.nexspace365.com/mcp.
AI agents (claude.ai, Claude Code, ChatGPT, Gemini, Cursor, custom) use this to
discover and invoke the governed NexSpace tool surface: named workforce verbs,
category-based platform tools (analytics, scheduling, CRM, compliance, and
more), knowledge-base research tools, and — when the credential’s bound user has
connected apps — third-party actions through those connections.
tools/list advertises the full governed catalog, not a per-credential
subset. Scope is enforced when you call a tool, not when you list one. See
What tools/list actually returns before
you build discovery logic on top of it.
Discovery
Four unauthenticated endpoints let a client bootstrap with zero prior configuration.Server metadata
protocolVersion, transport, the /mcp
endpoint, the advertised capabilities, and an auth block naming the
authorization-server metadata path, the protected-resource metadata path, the
dynamic-client-registration endpoint, and the authorize/token endpoints.
Protected-resource metadata (RFC 9728)
Served at both paths, because clients derive the URL either way per RFC 9728 §3.1:scopes_supported enumerates the entire scope catalog, so this endpoint — not
this page — is the machine-readable source of truth for scope names. Read it at
startup rather than hardcoding a list; the catalog grows as write waves ship.
401 bootstrap
An unauthenticated request toPOST /mcp (or GET /mcp/stream/{id}) returns
401 with a challenge pointing at the metadata above:
/.well-known/oauth-authorization-server, dynamic client registration, the
authorize/token endpoints) is documented in
Authentication.
Health check
verbCount is the
named-verb count, headlessToolCount the exposed registry tools, and
toolCount their sum. It does not include connected-app tools — those are
per-caller and need an authenticated tools/list. The counts grow as
categories are promoted, so read them rather than hardcoding them.
Protocol
All MCP communication happens viaPOST /mcp with JSON-RPC 2.0 messages.
Capabilities
initialize returns protocol version 2025-06-18 and this capability set:
resources and no prompts, and
listChanged: false means the server never emits
notifications/tools/list_changed — a client must not wait for one. Re-call
tools/list when you need a fresh view (for example after a user connects a
new app).
Methods
Any other method returns
-32601 with a suggestion listing the supported
methods.
Initialize
List Available Tools
What tools/list actually returns
Connected-app tools are the only per-caller entries in the list. A
third-party action appears only when both are true:
- your credential carries the matching
apps:read/apps:writescope, and - the credential’s bound user has an
activeconnection to that app’s toolkit.
fillShift and runPayroll in tools/list; calling one returns
-32003 FORBIDDEN naming the exact gap:
GET /api/auth/me (see Authentication) or the scope names
from the RFC 9728 metadata, then drop
tools you cannot call before handing the list to a model. Otherwise the model
will plan around tools that will reject it.
How scopes are matched over MCP
MCP scope matching is stricter than the REST API’s, and the difference silently breaks connectors. Over MCP a granted scope satisfies a required scope only when one of these holds:- you hold
*; - you hold the required scope exactly; or
- you hold the resource wildcard
<resource>:*for the required scope’s resource.
read / write scopes onto finer RBAC verbs (staff:read
satisfies staff.view, shifts:write satisfies shifts.create). The MCP
dispatcher does none of that. If the required scope string is not exactly what
you hold — or covered by a wildcard — you get -32003.
Two more scopes whose shape is not what you would guess:
notifyShiftSwaprequiresshifts:assign, notcommunications:write— it is a scheduling verb that happens to send messages.verifyCredentialrequirescredentials:verify, notcredentials:write.credentials:writeis not in the scope catalog at all.
shifts:assign is deliberately neither a read nor a write scope; it is its own
catalog entry and must be granted by name.
Call a Tool
Batching
POST /mcp accepts an array of JSON-RPC messages and returns an array of
responses. Notification entries produce no response and are omitted from the
returned array, so the response array can be shorter than the request array —
match responses by id, never by position.
200 with an empty array []. A
single (non-array) notification returns 204 with an empty body.
Batched entries are dispatched concurrently and each is governed independently
— one entry returning -32003 does not affect the others. Batching does not
bypass rate limiting: the whole POST is one request against your budget.
Streaming a Tool Call (Streamable HTTP)
SendAccept: text/event-stream on a tools/call POST /mcp to receive the
response as Server-Sent Events instead of a single JSON body. The stream carries
zero or more notifications/progress frames (only when you supply a
params._meta.progressToken) followed by the terminal JSON-RPC response, then
closes. Each SSE frame has a monotonic id.
The response includes an Mcp-Stream-Id header. If the connection drops before
the terminal response, reconnect to GET /mcp/stream/{streamId} with a
Last-Event-ID header to replay the frames you missed:
Accept: text/event-stream continue to get a single
JSON response, so this is fully backward-compatible. Streaming applies to
single tools/call messages only — an array body always returns a JSON array.
Available Tools
The surface has four layers. All of them share the same governance: org guardrails, role-based permissions, and risk-based approval gates apply to every call regardless of which layer a tool comes from.1. Named workforce verbs
Curated, stable verbs for the core workforce loops. The Scope column is the exact string the dispatcher checks — matched literally, with no read/write aliasing (see How scopes are matched over MCP). Six of these verbs are governed: a call may return a pending approval instead of executing immediately. See Approvals for which ones and when.Scheduling
Credentials
Staff
Payroll
Communications
CRM
Facilities
Approvals
Note that
listPendingApprovals is read-only but still requires
agents:approve — there is no separate read scope for the approval queue.
2. Platform tool registry
The same governed tools the in-app NexSpace AI assistant uses, exposed per category. Every read-only tool requires<category>:read; a write tool
requires <category>:write and is reachable only in a category that has been
promoted to read+write:
A write tool in a read-only category has no headless scope at all and is
simply absent from the surface —
compliance:read will never unlock a
compliance writer.
Registry writes flow through the same approval gate as the named verbs, and
get_approval_status reports where a pending action stands.
get_approval_status requires general:read. It is a read-only tool in the
general category, and every registry tool’s scope is derived as
<category>:<read|write> — there is no “available to any caller” tool on this
surface.general:read is in the default connector bundle, so a default read-only
connector already has it. But a credential minted with only write scopes — say
crm:write — is exactly the caller that needs to poll a pending approval and
will get -32003 when it tries. Any credential that requests write scopes
should also request general:read so it can follow up on its own approvals.3. Research tools (search / fetch)
Two read-only tools implementing the OpenAI deep research
contract. Both live in the general
category and are read-only, so both require general:read.
search
query. There is no limit or offset argument: the
service retrieves a fixed 12 knowledge-base chunks and dedupes them to
documents, keeping the best-scoring chunk’s title per document, so you will
usually see fewer than 12 results. Treat id as an opaque string to pass
straight back to fetch (it is a stringified numeric document id).
fetch
metadata is optional. A non-numeric or non-positive id fails with the message
“Invalid document id — use an id returned by search.”; an id your org and role
cannot reach fails with “Document not found or not accessible.”
Both are backed by the knowledge base through the RAG service, which applies
the caller’s organization, facility, and role conditions. fetch applies
exactly the same conditions as search, so it can never retrieve a
document search could not have returned.
Failures from either tool arrive as tool results with isError: true, not
as JSON-RPC errors — check isError on every call. Scope, tier, and
governance rejections still come back as JSON-RPC errors.
4. Connected-app actions
Withapps:read / apps:write, actions from connected apps (Slack,
QuickBooks, HubSpot, and more) appear in tools/list — but only for toolkits
with an active connection, and only with the real parameter schema fetched
from the broker. A tool whose schema cannot be resolved is skipped rather than
listed with a placeholder shape.
apps:read / apps:write are deliberately not in the default connector
bundle — reaching into an owner’s outside SaaS must be an explicit grant. They
are also distinct from integrations:write, which gates platform webhook
configuration and grants nothing here.
To discover what is connected before requesting apps:*, call
list_connected_apps. It is a read-only tool in the general category, so it
is gated on general:read, not on any apps: scope — it works on a
default read-only connector with zero apps scopes and returns each toolkit’s
slug, display name, status, and connection time. Connecting or disconnecting an
app stays a human act on the NexSpace Integrations page.
Dispatch runs the same governed path as registry tools, preceded by an active
connection check so a call can never create an approval that could not execute.
See Composio.
Not available over MCP
Five tools exist in the in-app assistant but are deliberately withheld from the MCP surface even though their category is exposed. They will not appear intools/list, and calling one by name returns -32004 NOT_FOUND:
Two structural exclusions apply on top of that denylist:
- Internal tools are never listed. Tools marked internal are registered only so the authorization layer can gate them from another execution path; they are resolver-only and unreachable over MCP.
- Composio tools are excluded from category exposure entirely. They never
arrive through a
<category>:readgrant — the only way a third-party action reaches MCP is the per-caller connected-apps lane above.
Error Codes
This table is not exhaustive.
-32001 is reserved and currently unused, and
new application codes may be added. Branch on the codes you handle and treat
any code you do not recognize as fatal rather than retrying it.
Missing or invalid credentials are rejected at the HTTP layer with a 401
and a WWW-Authenticate challenge (per RFC 9728) before any JSON-RPC
processing happens.
All error responses include a suggestion field with recovery guidance and
a retryable boolean in error.data.
Rate limiting
Exceeding the request budget returns HTTP429 carrying a JSON-RPC error body
with code -32000:
Retry-After rather than
re-firing. See Rate limits.
Model-tier floors
An organization can require a capable model — pro-or-better, say — for a specific tool. Your model is invisible to the server, so an external caller is graded instead by a credential-level tier equivalence. When that equivalence is unset the credential is ungraded and fails closed against any floored tool; tools with no floor (the vast majority) are unaffected. A-32003 raised for this reason carries requiredTier and tierEquivalence
in error.data:
fast, basic, pro, and max. Floors are set per tool by
an operator in AI Configuration → Tool Management; no tool ships with one by
default, so most callers never see this. Resolving it needs an operator to grant
the credential a tier equivalence or to lower the tool’s floor — retrying will
not help. Named verbs are checked against the same floor mechanism as registry
tools. See Model tier gate.
Approvals
Mutating tools are governed. Atools/call may return a pending approval
instead of a result. Unlike an error, this is a normal successful tool result —
there is no isError flag on it:
content[0].text is the JSON-serialized payload and structuredContent is the
same object — read whichever your client prefers. _meta.requestId echoes the
request’s X-Request-ID (or a server-generated UUID when you don’t send one)
and is also returned as the X-Request-ID response header, so it correlates the
call with its audit row.
approvalId is null only if the approval row could not be identified. Treat
status: "pending_approval" as a first-class outcome in every write path.
What decides whether a write pauses
Four inputs, in tighten-only composition — any one of them can force the gate, none can relax it below the floor. 1. The action’s risk level. With no explicit permission rule in play, risk alone decides:
2. The hard risk floor.
high and critical actions can never drop
below require_approval. No org permission rule can relax them; the floor is
applied after rule resolution, and the governance decision records
resolvedFrom: "risk_floor" when it bites. medium stays relaxable by an
explicit rule, and low is unconstrained.
Risk levels for the governed verbs:
3. Org guardrails. A guardrail policy can block a call outright (
-32003
with data.guardrail) or force the approval gate on a tool whose risk would
otherwise auto-execute. See Governance.
4. The headless autonomy dial. An operator can force require_approval on
every write for a given API key, for a facility, or for the whole fleet,
independently of risk. It is applied tighten-only — it can force the gate but
never remove it — and it never affects read tools.
That dial is the usual explanation for universal pending_approval responses
with no risk-level reason: a low-risk write that should auto-execute pauses
anyway because a key-level or fleet-level override is set. Resolution order is
documented in Governance.
Read-only tools are never gated. They skip the approval path entirely.
The loop from an agent’s perspective
- Call a mutating tool. If governance requires sign-off, the result carries
status: "pending_approval"and anapprovalId. NexSpace notifies the org’s approvers. - Poll
get_approval_statuswith that id (requiresgeneral:read) — it reports the lifecycle statuspending/approved/rejected/expiredplus, once resolved, the execution result or the rejection reason. Only approvals you submitted are visible. - A credential with
agents:approvecan resolve approvals raised by its own runs directly vialistPendingApprovals/approveAction/rejectAction. Self-approval is restricted to admin-level identities; everyone can reject (withdraw) their own pending actions.
Best Practices
- Read before write. Call read tools to confirm scope before mutating.
- Confirm with the user before calling mutating tools.
-
Respect
FORBIDDEN(-32003) — readerror.databefore you react. It is returned for seven different reasons, and only one is a scope gap:required/grantedpresent → a scope gap. Grant the exact scope inrequired(remember: no read/write aliasing over MCP).guardrailpresent → an org policy blocks it. See Governance.requiredTier/tierEquivalencepresent → a model-tier floor. See Model tier gate.suggestionreads “Omit facilityId to use your accessible facilities, or pass one your credential can access.” → a tenant-boundary violation.messageis either “facilityId […] is outside your accessible facilities (…)” or “No facilities are within your access scope.” See Tenant isolation.- “This tool requires an authenticated user identity” → the credential is org-only; governed tools need a bound user.
- “Connect the app on the NexSpace Integrations page” → no active app connection for the bound user.
- otherwise → your role, suite entitlement, or AI autonomy rules deny it.
-
Use
tools/listto discover tool names and schemas at runtime — then pre-filter it against your own scopes, because the server does not. -
Check
isErrorin tool call results for handler-level failures. Tool handlers report failure as a result withisError: true; only protocol, scope, and governance problems arrive as JSON-RPC errors. -
Handle pending approvals — treat
status: "pending_approval"as a normal outcome, not an error, and pollget_approval_status. Requestgeneral:readalongside any write scope so you can. -
Back off on -32000. Honor
Retry-After; batch reads into a single POST where you can. See Rate limits. -
Send an idempotency key on mutating calls you might retry — either the
Idempotency-KeyHTTP header orparams._meta.idempotencyKey. See Idempotency. MCP idempotency has different semantics from REST: it adds a distinctin_progressstate — a key whose call is still in flight is rejected with JSON-RPC-32009andretryable: true(“wait, then retry to replay the result”) — while a key replayed with different arguments returns the same-32009withretryable: false. Neither is an HTTP409.

