Ir al contenido
Seleccionar idioma:Español

MCP server

Esta página aún no está disponible en tu idioma.

Kept runs an MCP server at POST /mcp on the same process as the API. One tool set serves both MCP clients and Kept’s own assistant: the contracts live in packages/mcp, the handlers in apps/server/src/tools/. Code comments cite the decisions behind it as D63, D124 and D180 (decision labels).

Path What it holds
packages/mcp/src/tools.ts TOOL_DEFS: each tool’s name, title, description, scope, module and zod input and output
packages/mcp/src/output.ts The answer envelope and its size fit
packages/mcp/src/vocabulary.ts The server instructions and the kept://vocabulary resource
apps/server/src/mcp/routes.ts Mounts POST /mcp: Host and Origin checks, then the bearer token
apps/server/src/mcp/server.ts Builds one McpServer per request with the tools the token may call
apps/server/src/mcp/auth.ts Verifies a personal token or an OAuth access token
apps/server/src/tools/registry.ts, tools/handlers/ One handler per tool
apps/server/src/oauth/ The OAuth authorization server, consent and CIMD fetches
apps/server/src/tokens/ Personal tokens and the per-token rate limiter

Every tool takes an optional location_id (optional when the principal has one location) and answers {data, as_of, next_cursor?} or {error, hint}. Ids are UUIDs or 6-character short IDs. User-written strings in outputs sit under an untrusted key. Lists default to 20 items.

Tool Scope Module
capabilities read core
list_locations read core
search_things read core
where_is read core
get_thing read core
list_contents read core
thing_history read core
find_documents read core
upcoming read schedules
add_thing write core
update_thing write core
move_thing write core
mark_seen write core
create_place write core
update_place, turn_into_boxes write core
attach_link write core
log_reading write core
log_service write core
lend_thing, return_thing, borrow_thing write lending
complete_schedule, snooze_schedule write schedules
add_warranty, open_claim, update_claim write warranties
log_fuel write fuel
adjust_stock write consumables
list_tags read core
tag_things write core
list_moves, get_move read moving (MCP only)
start_move, add_box, pack_into_box, set_box_status, unpack_box write moving (MCP only)

A thing’s fields. add_thing’s items and update_thing‘s fields share one field list (thingFields in tools.ts): every field of POST /things (CreateBody) and PATCH /things/:id (UpdateFields) in apps/server/src/things/validate.ts, plus a consumable’s minimum (its own route). apps/server/src/tools/fields-parity.test.ts fails when either body gains a field the tools don’t take; its exclusions (the client id, the per-item location, the template shortcut) say why. The handlers write through insertThing() and updateThing(), so the money gate, the type’s custom-field validation and the audit are the routes’ own. update_thing’s purchase writes through the purchase service (createPurchase(), updatePurchase()), as the thing page’s purchase sheet does with the purchase routes (#348).

A place’s form. update_place takes what the place page changes: its name and kind (Rename), the place it is inside (Move; null is the top of the location), its kind’s fields, and child_order (Order of the places here); move: 'up' | 'down' is Move up / Move down in its parent’s Custom order. It calls updatePlace() and reorderChild() in apps/server/src/places/service.ts, so the role check, If-Match (the assistant’s card sends the place’s version), the custom-field validation and the audit are the route’s own: place.update or place.move, undoable, and place.reorder, not (the opposite step is its undo). turn_into_boxes groups the places by their parent and calls convertChildren() for each, the operation behind “Turn places into boxes”: one undoable place.convert_to_container per box, undone together by POST /api/v1/audit/undo. A place at the top of a location is refused (top_level): the app’s single conversion has no Undo.

No tool trashes, deletes, merges, transfers ownership, reveals a secret, uploads a file or runs SQL (D58, D63, D124). packages/mcp/src/tools.test.ts walks the names and fails on one that does. A tool that needs a file, such as a receipt, returns a short link from attach_link that opens the capture sheet in the web app.

A tool is offered only in locations where its module is on and, for a write tool, where the principal may write. capabilities lists what each location allows. Roles are checked on every call, never cached in a token (D180).

A tool’s doors say who is offered it: MCP and the in-app assistant, unless it names fewer. The Moving mode tools (D251) are MCP’s only; runTool() answers tool_unavailable at the other door. They call moving/service.ts, the routes’ own functions: packing is the ordinary move into the box’s container, and a box’s state change and an unpack are undoable for 7 days, as in the web.

Streamable HTTP, stateless (D63), with @modelcontextprotocol/server. mcp/server.ts calls createMcpHandler with a factory that builds a fresh McpServer for each HTTP request and registers only the tools that token may call. GET /mcp and DELETE /mcp answer 405. The route is mounted by hand on Fastify, so the security headers, the request log and x-request-id still apply.

Before the handler: the Host must be the public URL’s host (or a former one), and the Origin must be absent or Kept’s own. Both refusals are 403. Each answer carries the envelope once, as text, with no structuredContent, so a reply isn’t sent twice.

The bearer token is the only credential: /mcp takes no session cookie and needs no CSRF token. A failure is 401 with WWW-Authenticate: Bearer.

Personal tokens start kpt_ and are made in Settings → Connections (POST /api/v1/tokens). Each has a scope, read or write, and a list of locations. A token never goes above its creator’s role: a viewer’s token only reads. The same token works on the REST API.

OAuth is on only when the public URL is HTTPS (D125); http is allowed on a loopback host for development. Kept is the authorization server, through Better Auth’s jwt(), mcp() and cimd() plugins (apps/server/src/oauth/plugin.ts):

  • clients identify with a Client ID Metadata Document (CIMD): their client_id is a URL Kept fetches through its SSRF guard. Dynamic Client Registration is off;
  • authorization code with PKCE, and a consent screen where the person picks the scope (kept:read, kept:write) and the locations;
  • access tokens are JWTs for <public URL>/mcp, an hour long. The grant row holds the scope and locations and is read on every call, so revoking it in Connections works at once;
  • a connector stays signed in until it’s revoked: Kept adds offline_access so the client gets a refresh token, good for 30 days and renewed each time it’s used, so a connector idle for 30 days signs in again. Revoking the app in Connections ends it, and so does whatever signs out the person’s other devices (password, second factor, passkeys, outside sign-ins, email change, Sign out of all other devices, signing out everywhere, disabling the account); the connector then signs in again. Signing out one chosen device and asking to delete the account leave it signed in. Details on Authentication;
  • discovery documents are served at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource (and /.well-known/oauth-protected-resource/mcp).

The flow was proved with the official MCP client against a local fixture. Connecting hosted clients (claude.ai, ChatGPT) needs a public HTTPS URL and is still a pending check by the maintainer.

Limits and audit. One per-token limiter counts both API requests and MCP tool calls (reads 120 a minute, writes 30 a minute; tokens/rate.ts). Every write a tool makes is audited as the token.

Answer size. A tool’s answer stays under 8 KB of JSON. A list is shortened (with next_cursor), then the largest fields go, each listed in trimmed; ids, short IDs, names and a write’s undo ids (audit_event_ids, undo_until) are never removed. A write has already saved when it answers, so its answer is never an error: if even that won’t fit, it is cut to its ids and undo ids, with trimmed: ["*"]. A client must not retry a write because of trimmed.

When you create a token, the response includes ready-made client settings: the endpoint and the bearer header, nothing client-specific.

{
"url": "https://kept.example.org/mcp",
"headers": { "Authorization": "Bearer kpt_…" }
}

Put those two values wherever your MCP client takes a remote server’s URL and headers. The token is shown once.

  1. The contract. Add an entry to TOOL_DEFS in packages/mcp/src/tools.ts with tool({…}): a snake_case name, a title, a description written as the question it answers (English; the model reads it, the UI never shows it), scope, module (null for the core), since, and zod input and output. User-written strings in the output go under untrusted.
  2. The handler. Write it in apps/server/src/tools/handlers/ as a Handler<'your_tool'> with the action it needs (a role permission from packages/shared), subjectLocation, and run, which calls the same service function the REST route calls. Register it in HANDLERS in apps/server/src/tools/registry.ts. A contract without a handler is never offered.
  3. Tests. packages/mcp/src/tools.test.ts pins the table of names, scopes and modules, so update it. Add handler tests beside the others (apps/server/src/tools/tools.test.ts) and an end-to-end call in apps/server/src/mcp/mcp.test.ts if the tool needs one.
  4. The assistant gets the tool too. If it is a write tool, it shows as a proposal card there, which the web draws from the arguments (apps/web/src/assistant/confirm-rows.ts).

Location webhooks are built (apps/server/src/webhooks/). Owners and admins manage them on a location (/api/v1/locations/:id/webhooks); tokens can’t. Events are WEBHOOK_EVENTS: thing.created, thing.updated, thing.moved, thing.trashed, thing.restored, thing.lifecycle_changed, reading.logged, reminder.due.

Payloads never carry values: only ids, the event, the time, the actor and the names of the changed fields. A receiver fetches the entity with its own token. Each POST is signed: Kept-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. A failed delivery is retried 10 times over about 24 hours, then marked gave_up and the hook marked failing. A hook is disabled when its creator loses the role (D180).