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).
Where the code is
Section titled “Where the code is”| 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 |
The tools
Section titled “The tools”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.
Transport
Section titled “Transport”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.
Authentication
Section titled “Authentication”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_idis 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_accessso 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-serverand/.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.
Connecting a client
Section titled “Connecting a client”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.
Adding a tool
Section titled “Adding a tool”- The contract. Add an entry to
TOOL_DEFSinpackages/mcp/src/tools.tswithtool({…}): a snake_casename, atitle, adescriptionwritten as the question it answers (English; the model reads it, the UI never shows it),scope,module(nullfor the core),since, and zodinputandoutput. User-written strings in the output go underuntrusted. - The handler. Write it in
apps/server/src/tools/handlers/as aHandler<'your_tool'>with theactionit needs (a role permission frompackages/shared),subjectLocation, andrun, which calls the same service function the REST route calls. Register it inHANDLERSinapps/server/src/tools/registry.ts. A contract without a handler is never offered. - Tests.
packages/mcp/src/tools.test.tspins 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 inapps/server/src/mcp/mcp.test.tsif the tool needs one. - 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).
Webhooks
Section titled “Webhooks”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).