Skip to content

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.

  • Streamable HTTP, stateless. One POST /mcp per JSON-RPC message; no session ids, nothing to keep alive. GET and DELETE /mcp answer 405. Responses are application/json (or text/event-stream when the client asks for it); send Accept: application/json, text/event-stream.
  • Bearer tokens. The endpoint sits behind the normal authentication pipeline: Authorization: Bearer <access token>, either a user token from POST /v1/auth/login or an API-key token from POST /v1/auth/token ({ "apiKey": "…" }). Anonymous calls get 401. Per-principal rate limits and security headers apply as everywhere else.
  • Reaching /mcp at all needs the read-only policy: any staff role (admin, manager, specialist, boarding_checker, auditor_readonly) or an API key with the status:read scope. An API key used for MCP therefore always carries status:read, plus intake:write and/or evidence:read when 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 at https://<host>/mcp and the REST API at https://<host>/api/v1/…. Inside the compose network the api answers directly at http://api:8080/mcp. The api trusts X-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):

Terminal window
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.

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.

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.

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.

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.

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).

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).

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.

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.

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.

Terminal window
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”).

  • 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_service role. 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, processing or review; after that (including a sealed loan) the upload is a ConflictException. Submit with newVersion: true to 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 /mcp through the official MCP client SDK with a scoped API-key token: tool listing, allowed and refused calls, anonymous 401 and the audit rows.