MCP server
Bookend exposes a Model Context Protocol server at /mcp so a loan origination system, an internal automation, an assistant or an operator’s Claude session can drive the same operations as the REST API, with the same bearer tokens, the same authorization policies and a full audit trail. Every tool calls the same service the matching REST route uses; there is no MCP-only behavior.
Transport and authentication
Section titled “Transport and authentication”- Streamable HTTP, stateless. One
POST /mcpper JSON-RPC message; no session ids, nothing to keep alive.GETandDELETE /mcpanswer 405. Responses areapplication/json(ortext/event-streamwhen the client asks for it); sendAccept: application/json, text/event-stream. - Bearer tokens. The endpoint sits behind the normal authentication pipeline:
Authorization: Bearer <access token>, either a user token fromPOST /v1/auth/loginor an API-key token fromPOST /v1/auth/token({ "apiKey": "…" }). Anonymous calls get 401. Per-principal rate limits and security headers apply as everywhere else. - Reaching
/mcpat all needs theread-onlypolicy: any staff role (admin,manager,specialist,boarding_checker,auditor_readonly) or an API key with thestatus:readscope. An API key used for MCP therefore always carriesstatus:read, plusintake:writeand/orevidence:readwhen the session needs those tools. Each tool then enforces its own policy (see below), exactly as the matching REST route does. - Paths. Through
app-ui(the one published port) the server is athttps://<host>/mcpand the REST API athttps://<host>/api/v1/…. Inside the compose network the api answers directly athttp://api:8080/mcp. The api trustsX-Forwarded-*only from the proxy network, so audit rows carry the real client address.
Connect Claude Code with an API key (choose the scopes for what the session should be allowed to do):
TOKEN=$(curl -s -X POST https://<host>/api/v1/auth/token -H 'content-type: application/json' -d '{"apiKey":"<api key>"}' | jq -r .accessToken)claude mcp add --transport http bookend https://<host>/mcp --header "Authorization: Bearer $TOKEN"Access tokens expire after auth.access_token_minutes (default 15, at most 60). API-key tokens have no refresh token; exchange the key again to get a new one. Any client that speaks streamable HTTP works the same way.
Server metadata
Section titled “Server metadata”initialize returns serverInfo { name: "bookend", title: "Bookend Platform", version: <build version> } and short instructions describing the tools. Capabilities: tools only. There are no resources, prompts, sampling or server-initiated notifications (stateless mode).
| Tool | Policy | REST equivalent |
|---|---|---|
list_loans |
read-only |
GET /v1/loans |
get_loan |
read-only |
GET /v1/loans/{ref} |
get_findings |
read-only |
GET /v1/loans/{ref}/findings |
get_boarding_preview |
read-only |
GET /v1/loans/{ref}/boarding/preview |
submit_package |
intake-write |
POST /v1/loans + POST /v1/loans/{ref}/documents |
get_evidence_summary |
read-only |
summary of GET /v1/loans/{ref}/evidence/export |
export_evidence |
evidence-read |
GET /v1/loans/{ref}/evidence/export?format=json |
get_queue_stats |
read-only |
GET /v1/dashboard |
All results are JSON text in a single text content block, serialized like the REST responses (camelCase, money and rates as decimal strings, dates ISO-8601 UTC). Errors are also JSON text, with isError: true (see Errors). Parameter names are case-sensitive.
list_loans (read-only)
Section titled “list_loans (read-only)”| Parameter | Type | Default | Meaning |
|---|---|---|---|
state |
string? | none | Loan state filter: intake, processing, review, approved, boarding_staged, boarded, funded, sealed, rejected |
borrower |
string? | none | Borrower name contains (case-insensitive) |
hasExceptions |
boolean? | none | Only loans with unresolved exception findings |
page |
integer | 1 | 1-based page |
size |
integer | 25 | Page size, capped at 200 |
Result: the GET /v1/loans envelope, { items: LoanView[], page, size, total }. LoanView carries id, externalRef, packageVersion, borrowerName, expectedPrincipal (string), state, hasExceptions, assigneeId, createdAt, updatedAt, sealedAt.
get_loan (read-only)
Section titled “get_loan (read-only)”| Parameter | Type | Meaning |
|---|---|---|
externalRef |
string | The bank’s loan reference, e.g. BK-DEMO-001 |
Result: LoanDetail, { loan: LoanView, documents: LoanDocumentView[], versions: number[] }; each document has id, docType, originalName, sha256, pageCount, byteSize, uploadedAt. Unknown reference: NotFoundException.
get_findings (read-only)
Section titled “get_findings (read-only)”| Parameter | Type | Meaning |
|---|---|---|
externalRef |
string | Loan reference |
severity |
string? | exception, warning or info |
status |
string? | open, accepted, overridden, resolved (an escalation is an action on an open finding, not a status) |
Result: FindingView[] with id, runId, ruleId, severity, status, title, detail { message, sources[], data }, createdAt, blocksApproval. Every sources[] entry names the document, page and bounding box the finding cites; GET /v1/rules/{ruleId}/doc (REST) is the rule’s help page.
get_boarding_preview (read-only)
Section titled “get_boarding_preview (read-only)”| Parameter | Type | Meaning |
|---|---|---|
externalRef |
string | Loan reference |
Result: BoardingPreview, { loanRef, provider, fieldMapId, fieldMapVersion, fields: CoreFieldValue[], validation: { ok, errors[] } }. Each field carries the core field code, the transformed value, the transform used and its provenance (document, page, box). Nothing is staged, and the loan does not need to be approved for a preview.
submit_package (requires intake-write: roles admin, manager, specialist, or scope intake:write)
Section titled “submit_package (requires intake-write: roles admin, manager, specialist, or scope intake:write)”| Parameter | Type | Default | Meaning |
|---|---|---|---|
externalRef |
string | none | New loan reference (letters, digits, ., _, -) |
borrowerName |
string | none | Borrower legal name |
files |
{ fileName, contentBase64 }[] |
none | Base64-encoded PDF documents. A file named lar.json, lar.csv, lar.xml, lar.pdf or lar.docx in the same batch is ingested as the loan approval record (LAR). |
expectedPrincipal |
string? | none | Decimal string, optional pre-fill |
newVersion |
boolean | false | When the reference already exists, add a package version instead of failing with a conflict |
Result: { loan: LoanView, documents: LoanDocumentView[] }. Uploading starts the pipeline; poll get_loan (state processing, then review) or REST GET /v1/loans/{ref}/pipeline for stage detail. Errors: ConflictException when the reference exists and newVersion is false; ValidationException with fields[] for bad inputs (including a file that is neither a PDF nor a LAR); PolicyViolationException when the package would exceed documents.max_package_mb or the license has expired (intake is then read-only).
get_evidence_summary (read-only)
Section titled “get_evidence_summary (read-only)”| Parameter | Type | Meaning |
|---|---|---|
externalRef |
string | Loan reference |
Result: { loan, verification: { intact, brokenAtSeq, length }, events, documents: [{ id, fileName, sha256, documentType }], findings, approvals: EvidenceEventView[] }, where events and findings are counts. verification is a fresh re-hash of the chain, not a cached value.
export_evidence (requires evidence-read: any staff role, or scope evidence:read)
Section titled “export_evidence (requires evidence-read: any staff role, or scope evidence:read)”| Parameter | Type | Meaning |
|---|---|---|
externalRef |
string | Loan reference |
Result: the full EvidenceBundle, identical to GET /v1/loans/{ref}/evidence/export?format=json: generator, institution, loan, every document with its hash, the findings of the latest run, approvals, every chain event with payload and hashes, and the verification result. It can be large (tens of KB per loan). The PDF packet is available over REST only (format=pdf).
get_queue_stats (read-only)
Section titled “get_queue_stats (read-only)”| Parameter | Type | Default | Meaning |
|---|---|---|---|
days |
integer | 30 | Window, clamped to 1 to 365 |
Result: DashboardView, with queue { intake, processing, review, approved, withExceptions }, throughput[] per day, averageReviewMinutes, reviewsMeasured, exceptionRateByRule[] and aging[]. Every figure derives from recorded evidence events.
What MCP does not do
Section titled “What MCP does not do”Decisions stay with signed-in people. There are no MCP tools for accepting or overriding findings, approving or rejecting a loan, staging, approving or committing a boarding, staging or exporting a wire, funding or sealing. Over REST those routes also refuse API-key tokens.
Errors
Section titled “Errors”A refused or failed call returns isError: true with a JSON text block:
{ "error": "ForbiddenException", "message": "The tool 'submit_package' requires the 'intake-write' policy (role or API-key scope).", "fields": null}error is the exception type: ForbiddenException (policy), NotFoundException, ConflictException, ValidationException (with fields: [{ field, message }]) or PolicyViolationException (for example an expired license or a package over the size cap). message is the same text the REST API puts in its problem+json. Unexpected failures surface as the SDK’s generic tool error. A 401 (missing or expired token) happens at the HTTP layer, before JSON-RPC.
Example (raw JSON-RPC)
Section titled “Example (raw JSON-RPC)”curl -s https://<host>/mcp \ -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_findings","arguments":{"externalRef":"BK-DEMO-002","severity":"exception"}}}'tools/list returns the eight tools with their JSON schemas (the parameter tables above come from the same signatures). initialize is not required in stateless mode but is accepted.
Every call, allowed or refused, writes an auth_events row with event_type = 'mcp_call' and detail_json = { tool, principal, kind, argsSha256, resultBytes, ok, error, elapsedMs } plus the caller’s IP address and user agent. Arguments themselves are not stored (they may contain document bytes); their SHA-256 is, so a call can be correlated with what the caller logged. Administrators and managers can query it with GET /v1/audit?source=auth&eventType=mcp_call or on System → Audit (tab “Sign-ins & API”, preset “MCP calls”).
Operational notes
Section titled “Operational notes”- The MCP surface adds no background work: a tool runs inside the request and returns. Long-running effects (
submit_package) are the same durable pipeline jobs the UI uses. - Tokens issued for API keys carry the key’s scopes and the
api_servicerole. Revoking the key (DELETE /v1/api-keys/{id}) blocks new exchanges immediately; tokens already issued stay valid until they expire. - Documents can be added only while a loan is in
intake,processingorreview; after that (including a sealed loan) the upload is aConflictException. Submit withnewVersion: trueto start a new package version instead. - The server is stateless, so a load balancer needs no sticky sessions.
- Wire compatibility is covered by the release’s contract tests, which drive
/mcpthrough the official MCP client SDK with a scoped API-key token: tool listing, allowed and refused calls, anonymous 401 and the audit rows.