Tích hợp

MCP agents

Connect an AI agent with an API key. What it is handed is an intent — a job — not a list of endpoints, the list is filtered to what that key can do, sensitive jobs ask first, and every call lands in the audit log.

Cập nhật lần cuối

Who this is for: Owner — requires integration.mcp.connect, and integration.job.run to submit jobs. Where: Integrations → MCP Agents

An MCP agent is an AI system you let operate the hotel. Nerve exposes its capabilities at /api/mcp, and an agent connects with an ordinary API key — the same kind of credential a script would use, carrying the same permissions, writing to the same audit log.

That is the whole design, and it is worth saying plainly before the mechanics: an agent is not a new kind of user with a new kind of access. It is a credential inside the model you already have. See Open Hotel Protocols for why the product is built this way.

What an agent is handed is an intent, not an endpoint#

This is the part that changed, and it changes how you should think about the whole surface.

The original tool set mapped one-to-one onto API calls — list_reservations, create_guest, and so on. An agent that wanted to check a guest in had to discover those primitives, sequence them itself, and get the order right, including the order in which money moves. Three things followed, and all three were bad:

  • the orchestration lived outside the boundary that governs it, in a client nobody here reviews;
  • the audit trail was a sequence of unlabelled calls whose intent had to be inferred afterwards — which is exactly when inference is least reliable;
  • the confirmation gate bound each fragment rather than the decision, so a human approved four writes and nobody ever approved "check this guest in".

A job is the repair. A job is one declared intent — check a guest in, settle a folio — carrying its input and output schema, the permission set it resolves, the authority envelope it runs inside, whether it is sensitive, and the primitives it is allowed to call. The agent asks for the outcome. Nerve does the sequencing, inside the boundary that governs it.

The MCP Agents screen's Jobs section: check_in_reservation (sensitive), quote_and_hold and report_period_performance, each showing its version, the permissions it resolves, the internal primitives it composes, and its authority envelope

Each job declares everything about itself on one row: the permissions it resolves, the primitives it composes — get_arrivals, check_in_stay, get_stay_folio — and its authority envelope, in words. "May assign a free room of the booked type… may not override the room-readiness gate."

Quan trọng

Jobs roll out per organisation. The job surface ships behind a flag. Until it is switched on for your organisation, the job endpoints and the job tools report as though jobs do not exist — that is the intended dark state, not a fault. The per-endpoint tools below keep working throughout.

The starter jobs#

Job Intent Sensitive
check_in_reservation Verify the stay, assign a room if needed, open the folio and take any deposit. yes
settle_folio Take the closing payment if one is needed, then issue the invoice. yes
quote_and_hold Quote a stay for a date range and report the offers that are actually sellable.
report_period_performance Occupancy, ADR and RevPAR for a period, with the bookings behind them.

Each one resolves its own permission set. A key sees a job only if it holds every permission that job resolves, plus integration.job.run.

Connect an agent#

  1. Create an API key with integration.mcp.connect, plus integration.job.run, plus the permissions behind each job or tool the agent should reach.
  2. Point the agent at POST /api/mcp.
  3. Send the key as Authorization: Bearer nrvk_….

The endpoint speaks single-shot JSON-RPC over POST — one request, one response, no session to hold open.

curl -X POST http://localhost:8080/api/mcp \
  -H "Authorization: Bearer nrvk_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Quan trọng

integration.mcp.connect is a separate permission from the tools. A key with crs.booking.read and nothing else is refused at the door — it never gets as far as being told which tools exist.

What tools/list returns, in order#

Jobs first, then job_status, then whichever per-endpoint tools are still inside their deprecation window. The order is deliberate: an agent picks from the top of a list, and the thing it should be picking is "check this guest in", not the four primitives it would otherwise have to sequence.

The primitives those jobs call — check_in_stay, take_folio_payment, quote_stay and the rest — are internal. They never appear on this list at all, for any key. They exist to be composed into an intent, not handed out as fragments.

The list is filtered, not just enforced#

This is stronger than it sounds. A key does not see what it cannot use and then get refused. It does not see it.

Calling something that was filtered out does not produce a permission error — it produces unknown tool:

{
  "error": { "code": -32601, "message": "unknown tool: get_kpis" }
}

That is deliberate. An agent reasons about what it can do from the list it was given, so a filtered surface is the only surface it knows about — and a refusal never tells it what it was not allowed to see.

The same shape holds on the job endpoints: a job that does not exist and a job you may not run answer identically. Telling an under-scoped caller that a job exists but is out of reach is a disclosure.

The practical consequence: scope the key, not the prompt. An instruction not to touch guest data is a request; a key without frontdesk.guest.read is a fact.

Submitting a job over HTTP#

Jobs are also a plain REST surface, for callers that are not speaking MCP.

# What can this key actually run?
curl -H "Authorization: Bearer nrvk_..." \
  http://localhost:8080/api/v1/jobs

# Submit an intent. The idempotency key is yours to choose.
curl -X POST http://localhost:8080/api/v1/jobs \
  -H "Authorization: Bearer nrvk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "job": "report_period_performance",
    "arguments": { "start": "2026-08-01", "end": "2026-08-31" },
    "idempotencyKey": "august-report-1"
  }'

The response is a durable handle — an id, a status, and the intent and authority envelope echoed back so the caller can see what it just authorised.

Call What it does
GET /api/v1/jobs The jobs this key may run. A job it could not run is not listed.
POST /api/v1/jobs Submit an intent. 201 for a new handle, 200 for a repeat.
GET /api/v1/jobs/{id} Where the run got to.
GET /api/v1/jobs/{id}/result The output. 409 while it is still running.
GET /api/v1/jobs/{id}/stream The same progress, as it happens.

Two details worth knowing before you build against it:

  • Re-submitting is free. The same idempotencyKey returns the same handle with 200 instead of 201, and runs nothing twice. A caller that loses its connection retries the submission rather than reasoning about whether the check-in happened.
  • "Still running" and "finished with nothing" are different answers. Asking for a result too early is a 409, not an empty body.

The handle is state about the job, never about the agent. There is no session, no stored cognition, nothing that remembers a caller between submissions. Nerve runs a loop for the job; it does not become your agent's runtime.

A sensitive job asks before it acts#

Submitting a sensitive job without confirmation is refused with confirmation_required. Re-submit with "confirm": true and it is accepted.

This is a deliberate speed bump between "the model decided to" and "the hotel did" — and now it sits on the decision rather than on each fragment of it. Confirming check_in_reservation approves checking the guest in, which is a thing a person can actually agree to.

It is not a substitute for scoping the key. An agent that holds the permissions and confirms will check the guest in.

Every call is in the audit log#

  1. Open Settings → Audit Log.

The audit log listing MCP and API calls by key prefix, with ok, denied and error outcomes

Every call, including the refused ones. An agent's tool call sits in the same log as a person's action, and the denials are recorded as carefully as the successes.

Each row carries the key prefix as the actor, the channel (mcp for a tool call, api for the transport call underneath it, app for a human action), the tool or endpoint name, and the outcome.

A job is recorded as one record naming the intent — with its authority envelope, the permissions it ran under, and every primitive beneath it — at submission and again at completion. That is the difference the whole change is for: the log says "check in reservation 4471", not four calls someone has to reassemble into a guess.

Two things follow. There is no separate "AI log" to go and find — an agent's activity is in the same place as everything else, which is what makes "who did this" answerable at all. And a denied row is evidence, not noise: it is the record of the permission model refusing something, and a run of them is how you notice an agent trying to do a job it has not been scoped for.

The per-endpoint tools are deprecated#

The five original tools still answer, and every call inside the window carries a notice naming the job that replaces it and the date it stops answering. They become internal primitives on 1 March 2027.

The settle_folio job above the MCP tools section, where list_reservations, list_guests and list_bookings each carry a deprecated badge, the job that supersedes them, and the date they stop answering external calls: 2027-03-01

The seam, on one screen. Above the divider, jobs. Below it, the same capabilities as per-endpoint tools — each badged deprecated, each naming its superseding job and its sunset date, so an integrator learns from the surface rather than from a changelog they may never read.

Tool Superseded by
list_reservations check_in_reservation
list_guests check_in_reservation
list_bookings report_period_performance
get_kpis report_period_performance
create_guest check_in_reservation

That date is set in the source rather than in configuration, on purpose: a deprecation window is a promise made to integrators, and a promise that can be shortened by an environment variable is not one.

list_engagements and submit_debrief are not deprecated — they belong to Protocols, which is an engagement surface rather than a per-endpoint one.

Every tool and every job is bound to the key's property. There is no argument for choosing a hotel and no way to reach another one, so an agent scoped to one property cannot read across a group.

Turning it off#

An Owner can disable a single job or stop the job surface entirely from the registry. A disabled job then answers exactly as if it had never existed; a stopped surface answers job_surface_disabled. Neither corrupts a handle that already exists.

What's next#