Skip to main content

Model Context Protocol

NexSpace exposes a JSON-RPC 2.0 MCP server at https://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

Returns the server name and version, 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 to POST /mcp (or GET /mcp/stream/{id}) returns 401 with a challenge pointing at the metadata above:
An OAuth-capable client can therefore discover the authorization server from the 401 alone — no pre-configuration. The authorization server’s own metadata (/.well-known/oauth-authorization-server, dynamic client registration, the authorize/token endpoints) is documented in Authentication.

Health check

Unauthenticated, cheap, and safe to poll for connectivity. 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 via POST /mcp with JSON-RPC 2.0 messages.

Capabilities

initialize returns protocol version 2025-06-18 and this capability set:
Tools only. There are no 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

Returns tool names, descriptions, input schemas, and annotations (read-only, destructive, idempotent hints).

What tools/list actually returns

tools/list is not filtered by your credential’s scopes. It advertises the full governed catalog — all 19 named verbs plus every headless-exposed registry tool — regardless of what your API key or OAuth token was granted.
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:write scope, and
  • the credential’s bound user has an active connection to that app’s toolkit.
Everything else in the list is static. A read-only connector token still sees fillShift and runPayroll in tools/list; calling one returns -32003 FORBIDDEN naming the exact gap:
Pre-filter the listed tools against your own token’s scopes rather than assuming the server did it for you. Get your granted scopes from 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:
  1. you hold *;
  2. you hold the required scope exactly; or
  3. you hold the resource wildcard <resource>:* for the required scope’s resource.
There is no read/write action aliasing over MCP. The REST authorization layer maps coarse 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.
The crm:* trap. Two named verbs, searchLeads and qualifyLead, require the literal scope crm:*. The default connector bundle — what an OAuth client gets when it omits scope entirely — grants crm:read, not crm:*. crm:read does not satisfy crm:*, so a default connector token can call the registry tool search_leads but is rejected on the searchLeads verb. Request crm:* explicitly if you want the CRM verbs.
Two more scopes whose shape is not what you would guess:
  • notifyShiftSwap requires shifts:assign, not communications:write — it is a scheduling verb that happens to send messages.
  • verifyCredential requires credentials:verify, not credentials:write. credentials:write is 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.
A batch containing only notifications returns 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)

Send Accept: 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:
Clients that don’t send 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
The only input is 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.
These cover the knowledge base only — policies, procedures, compliance documents, handbooks, training material. They do not search shifts, staff, or CRM records (use the workforce verbs and registry tools for those), and they do not search the open web (that is the separate web_search registry tool, also general:read).
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

With apps: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.
These are the bound user’s connections, not the organization’s. In v1 the lane lists and dispatches only the connections belonging to the credential’s own bound user — the same set the in-app assistant sees for that user. Org-owned connections (the social rails) stay in-app and never appear over MCP. An empty connected-app list usually means this user has not connected anything, even when colleagues have.
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 in tools/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>:read grant — the only way a third-party action reaches MCP is the per-caller connected-apps lane above.
For the authoritative list of what is exposed, see the Tool catalog.

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 HTTP 429 carrying a JSON-RPC error body with code -32000:
The budget is per credential (per API key, else per user, else per IP) over a 60-second window, and a key can carry its own higher limit. This is the most likely error a fanning-out agent hits — honor 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:
The four tiers are 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. A tools/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:
runPayroll is HIGH risk, so it sits at the un-relaxable floor. Whenever the call is authorized at all it returns status: "pending_approval" and never commits inline, no matter how permissive the org’s rules are (an unauthorized caller gets -32003 instead). Build the payroll path around the approval round-trip, not around an inline result.
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

  1. Call a mutating tool. If governance requires sign-off, the result carries status: "pending_approval" and an approvalId. NexSpace notifies the org’s approvers.
  2. Poll get_approval_status with that id (requires general:read) — it reports the lifecycle status pending / approved / rejected / expired plus, once resolved, the execution result or the rejection reason. Only approvals you submitted are visible.
  3. A credential with agents:approve can resolve approvals raised by its own runs directly via listPendingApprovals / approveAction / rejectAction. Self-approval is restricted to admin-level identities; everyone can reject (withdraw) their own pending actions.
Approved actions execute with the original caller’s identity and scope — an approver’s broader permissions are never borrowed for execution.

Best Practices

  1. Read before write. Call read tools to confirm scope before mutating.
  2. Confirm with the user before calling mutating tools.
  3. Respect FORBIDDEN (-32003) — read error.data before you react. It is returned for seven different reasons, and only one is a scope gap:
    • required / granted present → a scope gap. Grant the exact scope in required (remember: no read/write aliasing over MCP).
    • guardrail present → an org policy blocks it. See Governance.
    • requiredTier / tierEquivalence present → a model-tier floor. See Model tier gate.
    • suggestion reads “Omit facilityId to use your accessible facilities, or pass one your credential can access.” → a tenant-boundary violation. message is 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.
    None of these are retryable without a configuration change. Do not loop.
  4. Use tools/list to discover tool names and schemas at runtime — then pre-filter it against your own scopes, because the server does not.
  5. Check isError in tool call results for handler-level failures. Tool handlers report failure as a result with isError: true; only protocol, scope, and governance problems arrive as JSON-RPC errors.
  6. Handle pending approvals — treat status: "pending_approval" as a normal outcome, not an error, and poll get_approval_status. Request general:read alongside any write scope so you can.
  7. Back off on -32000. Honor Retry-After; batch reads into a single POST where you can. See Rate limits.
  8. Send an idempotency key on mutating calls you might retry — either the Idempotency-Key HTTP header or params._meta.idempotencyKey. See Idempotency. MCP idempotency has different semantics from REST: it adds a distinct in_progress state — a key whose call is still in flight is rejected with JSON-RPC -32009 and retryable: true (“wait, then retry to replay the result”) — while a key replayed with different arguments returns the same -32009 with retryable: false. Neither is an HTTP 409.