LeashPass MCP server
The Model Context Protocol (MCP) is a small open standard that lets an LLM call your APIs as tools, with structured input schemas and rich descriptions written for AI consumption. LeashPass is built MCP-first: every read and workflow operation the REST API exposes is also available as an MCP tool, so an agent can drive the entire screening workflow without any custom integration glue.
Status: shipped (Phase 1d). Read access and workflow transitions are live. Document upload via MCP and entity creation tools (
create_pet,create_profile) are deferred to Phase 1d.5; use the REST API for those for now.
Why MCP
Three reasons MCP is the right shape for screening:
- Tool descriptions written for AI. MCP carries rich English descriptions on every tool and field. The agent doesn't have to guess what a parameter does — the description tells it.
- Structured input/output. Every tool ships a JSON Schema with
additionalProperties: false, explicitrequiredlists, and ULID pattern constraints. The MCP client validates inputs before they reach the server; the server validates again at the wire boundary. - One identity per session. A single API key authenticates the SSE
connection; every tool call inherits the key's scopes. The
LeashPass-On-Behalf-OfandLeashPass-Acting-Userheaders map the agent's actions to a specific human for audit purposes.
Phase 1f.1 parallel surface. LeashPass also exposes an in-app chat backend at
/chat/conversations/...that reuses the same tool catalog throughServer.ExecuteTool. The MCP server here is for external agents (Claude Desktop, customer PMS integrations); the in-app chat is for internal LeashPass users with a session cookie. Both surfaces emit the samemcp.tool.<name>.*audit rows. Seedocs/chat.mdfor the chat HTTP+SSE contract.
Connection
The MCP server accepts Server-Sent Events (SSE) connections at:
GET https://your-leashpass-host/mcp/sse
Authorization: Bearer lp_live_...
The first SSE event the server sends is event: endpoint whose data is the
URL the client should POST JSON-RPC requests to (e.g.
/mcp/messages?sessionId=...). Each POST is acknowledged with 202 Accepted; the actual JSON-RPC response is pushed back over the SSE stream
as event: message. A heartbeat comment is emitted every 25 s to keep
proxies from killing idle connections.
The wire protocol is JSON-RPC 2.0; LeashPass speaks the 2024-11-05
revision of the MCP spec. The hand-rolled transport is documented in
docs/adr/0010-mcp-protocol-handroll.md;
clients like Claude Desktop, Claude Code, and the official MCP SDKs handle
the wire format for you.
Configuration: Claude Desktop
Add LeashPass to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"leashpass": {
"transport": "sse",
"url": "https://demo.leashpass.com/mcp/sse",
"headers": {
"Authorization": "Bearer lp_live_abcdef...",
"LeashPass-Acting-User": "agent:claude-desktop"
}
}
}
}
Restart Claude Desktop and the LeashPass tool catalog appears in the MCP
picker. The first request fetches the tool list; subsequent calls are
cheap. See docs/mcp-demo.md for a full step-by-step
walkthrough including the API-key minting flow.
Configuration: agent backends
For agent backends (LangChain, CrewAI, custom orchestration) use any MCP-compatible client library:
- @modelcontextprotocol/sdk (Node)
- mcp (Python)
The same SSE endpoint and headers apply; the SDK abstracts the wire format.
Audit-grade contract
Every MCP tool call writes to the same audit log REST handlers write to.
Workflow tools (today: transition_profile; tomorrow: any new write
operation) emit two audit rows per invocation:
| Row | Action | Resource | Written by |
|---|---|---|---|
| MCP-context (transport-scoped) | mcp.tool.<tool_name>.success / .failure |
(mcp_tool, <tool_name>) |
Dispatcher audit hook |
| Domain (resource-scoped) | profile.approved (or .rejected, .under_review, .needs_info, .submitted, .draft) |
(pet_profile, <profile_id>) |
Tool handler |
Both rows carry the API key ID, the on-behalf-of header, the acting-user header, the originating IP, the User-Agent, and the X-Request-Id from the JSON-RPC POST.
This is deliberate: an admin querying the audit feed by (resource_type, resource_id) for "every event on this profile" must see every transition
regardless of which API surface drove it. If the only audit row written
were the MCP-context one, that filter would silently miss MCP-driven
transitions and the audit log would no longer be a single source of
truth. Future write tools must follow this same dual-event pattern.
Read tools (everything except transition_profile) emit only the
MCP-context row — no domain event applies because no state changed.
Tool catalog
Tools fall into three groups: reads (12 tools, scoped by *:read),
workflow (2 tools, scoped by profiles:review — transition_profile
forward-flow and reopen_profile reverse-flow), and manager
write-back (3 tools, Phase 1e — scoped by pets:write,
profiles:write, profiles:notify respectively). Every tool's
inputSchema is a JSON Schema document with additionalProperties: false; the schemas are embedded in the binary at build time and shipped
as part of the tools/list response.
Parameter naming: one canonical spelling per field
LeashPass schemas declare exactly one accepted name for each
parameter. Aliases (e.g. target_status alongside to_status, or
pet_id alongside id) are deliberately NOT supported.
- The status-change parameter is
to_statusin bothtransition_profileandreopen_profile. Calls that sendtarget_status,new_status,status, or anything else are rejected by the validator withAdditional property X is not allowed. - The profile reference is
profile_id(neveridorprofile). - The reopen justification is
reason_note(neverreasonorjustification). - The transition note is
note(nevermanager_noteorcommenton the wire —manager_noteis the column name written internally byupdate_profile_manager_note).
The trade-off: aliases are convenient for agents but they bloat the API surface, double the test matrix, and make migrations harder. Instead, every tool description and every field description explicitly names the parameter — when an agent's first call uses the wrong spelling, the error message points at the schema's canonical name and a retry succeeds.
If a single-name convention causes an agent friction in your deployment, the right fix is usually to update the agent's system prompt with a one-line cheat sheet of the canonical names rather than adding aliases on the server.
Human-readable display fields (Phase 1e.1)
Every entity-returning tool result includes a display field with
a pre-formatted, one-line summary suitable for rendering directly to
users. Agents should prefer this for conversational output; the id
field stays in the result for follow-up tool calls.
The canonical formats:
| Entity | Example |
|---|---|
| Property | Acme Apartments East — 1100 Eastview Drive, San Diego, CA |
| User | Maria Garcia (maria.garcia@example.com) · non-residents get a role suffix: [admin], [manager], [operator] |
| Pet | Buddy (Golden Retriever, 5y, spayed/neutered) — owned by Maria Garcia |
| Profile | Buddy (Golden Retriever) for Maria Garcia at Acme Apartments East — under_review, score 4 |
| Document | vaccination-buddy.pdf (PDF, 192 KB) — uploaded 2026-03-15 |
Variations covered by the renderer:
- Service animal / ESA: the score is replaced by the FHA-exempt
classification.
… — approved, service_animal (FHA exempt)/… — approved, esa (FHA exempt). - Draft profile:
… — draft, not yet scored(the rubric only runs on submit). - Empty optional fields: skipped. A pet with no breed and no
spay/neuter info renders as
Whiskers (10y) — owned by …rather thanWhiskers (, 10y, ) — owned by …. - Missing related entity: graceful
[unknown]placeholders, e.g.Buddy (Golden Retriever) for [unknown] owner at Acme Apartments East — needs_info. We don't fail the call because an orphan row exists. - Date format: ISO 8601 calendar days (
2026-03-15). Times are omitted unless meaningful — the upload moment of a document is not. - Content types: normalised to short labels for documents —
PDF,JPEG,PNG,HEIC. Unknown MIME types pass through unchanged. - Byte sizes: IEC binary units (1 KB = 1024 B), single-decimal precision at MB and above.
The display field is English-only for v0.1 (no i18n). It is NOT
emitted on workflow tools that return status confirmations rather
than entity records: transition_profile, update_pet,
update_profile_manager_note, reopen_profile, and
notify_resident_about_profile return entity DTOs (so the field IS
present there) or simple {sent: true} payloads. get_profile_audit
and explain_profile_score have their own response shapes and do
not carry display.
Read tools
list_properties
Lists every (non-archived) property in this organization, sorted by name.
The agent should call this first when the user references a property by
name to discover the property_id needed by other tools.
- Scope:
properties:read - Input:
{} - Output:
{"properties": [Property…]}
get_property
Fetches a single property by ID: name, slug, address, status (active or archived).
- Scope:
properties:read - Input:
{ "property_id": "prop_..." } - Output:
Property
list_residents
Lists users with the resident role in this organization, sorted by
email. Pass property_id to limit to residents who have at least one pet
profile at that property.
- Scope:
users:read - Input:
{ "property_id": "prop_..." }(optional) - Output:
{"residents": [User…]}
get_user
Fetches a single user (resident, manager, admin, or operator) by ID: email, role, timestamps. Password material and session data are never returned.
- Scope:
users:read - Input:
{ "user_id": "<26-char ULID>" } - Output:
User
list_pets
Lists pets in this organization, sorted by name. Pass owner_user_id to
filter to a single resident's pets.
- Scope:
pets:read - Input:
{ "owner_user_id": "<26-char ULID>" }(optional) - Output:
{"pets": [Pet…]}
get_pet
Fetches a single pet record: name, species, breed, sex, spayed/neutered, weight, microchip ID, date of birth.
- Scope:
pets:read - Input:
{ "pet_id": "pet_..." } - Output:
Pet
list_profiles
Lists pet screening profiles, sorted by creation time (newest first).
Filters: property_id, status, resident_user_id. Without filters,
returns the full org list.
- Scope:
profiles:read - Input:
{ "property_id": "prop_...", "status": "submitted | under_review | approved | rejected | needs_info | draft", "resident_user_id": "<26-char ULID>" } - Output:
{"profiles": [ProfileSummary…]}
get_profile
Fetches a complete pet screening profile: pet info (name, species, breed), every answered question (vaccinations, vet contact, behavior, time alone, bite history, training), current review status, manager notes, score breakdown if scored, and the IDs/types of every attached document.
- Scope:
profiles:read - Input:
{ "profile_id": "petprof_..." } - Output:
ProfileDetail(embedsPetand[]DocumentRef)
get_profile_audit
Returns the recent audit history for a profile (newest first): state transitions, document attachments, manager notes, updates. Default limit 50, max 200.
- Scope:
profiles:read - Input:
{ "profile_id": "petprof_...", "limit": 50 } - Output:
{"events": [AuditEvent…]}
explain_profile_score
Returns a detailed explanation of why a profile received its score, including each rule that fired and how it contributed to the total. Use this when a manager asks 'why' a profile was scored a certain way.
Service animals and ESAs return classification only with no score
(Fair Housing Act exemption). Profiles still in draft return NotFound.
- Scope:
profiles:read - Input:
{ "profile_id": "petprof_..." } - Output:
{ "score": 4, "label": "Approve", "classification": "regular", "rubric_version": "0.1.0", "rubric_hash": "…", "scored_at": "…", "breakdown": [ {"id":"vaccinations_current","points":25,…} ] }
See docs/scoring.md for the rubric definition.
list_documents_for_profile
Lists every (non-deleted) document attached to a profile, with metadata
only. Use get_document_url to mint a download link.
- Scope:
documents:read - Input:
{ "profile_id": "petprof_..." } - Output:
{"documents": [Document…]}
get_document_metadata
Fetches metadata for a single document: type, original filename, content type, size, status, uploader, timestamps. The bytes are NOT included.
- Scope:
documents:read - Input:
{ "document_id": "doc_..." } - Output:
Document
get_document_url
Mints a short-lived (5 minute) signed download URL for a document. The agent surfaces the URL to the user as a clickable link in chat — it does not store the URL or include it in long-lived outputs.
- Scope:
documents:read - Input:
{ "document_id": "doc_..." } - Output:
{ "document_id", "url", "expires_at", "original_filename", "content_type", "size_bytes" } - Errors:
CodeConflictif the document is not yetready;CodeValidationif document storage is not configured on the server.
Workflow tools
transition_profile
Changes a pet profile's review status. Allowed transitions:
draft→submitted, submitted→under_review,
under_review→{approved, rejected, needs_info}, needs_info→draft.
Other transitions return CodeConflict.
The agent must pass a non-empty note when transitioning to
rejected or needs_info — the resident reads it. The handler returns
CodeValidation if the note is missing for those transitions.
- Scope:
profiles:review - Input:
{ "profile_id": "petprof_...", "to_status": "approved | rejected | needs_info | submitted | under_review | draft", "note": "Pet rent of $50/month required." } - Output: refreshed
ProfileSummary - Side effects: writes both
mcp.tool.transition_profile.successANDprofile.<status>audit rows (see Audit-grade contract). - Auto-email: when
to_status=needs_infoandnoteis non-empty, the resident receives a templated email containing the manager's note and a deep link back to the application. Failures are fail-soft (the status change is durable; the failed send is captured in the audit log underemail.failed.notify_resident). Phase 1e §Step 4.
reopen_profile (Phase 1e)
Reopens a previously-decided profile, returning it to under_review
(most common: "we got it wrong but the answers don't need to change")
or draft ("the situation has changed; the resident must update their
answers"). Profiles still in review return CodeConflict.
The handler clears the prior reviewed_at + reviewed_by_user_id so
the per-resource timeline reflects the reversal cleanly. The
manager_note from the prior decision stays on the row (the rationale
for the original approve/reject is still discoverable from history);
the reason_note for THIS reopen lands in the audit row, not on the
profile.
- Scope:
profiles:review(same scope as forward flow — reversing a decision is a review action). - Input:
{ "profile_id": "petprof_...", "to_status": "under_review | draft", "reason_note": "Approval was based on a vaccination record we have since determined to be expired." }reason_noteminimum 10 characters of meaningful prose (handler-level whitespace check; schema'sminLengthwould let "10 spaces" pass). - Output: refreshed
ProfileSummary - Side effects: writes BOTH
profile.reopened(with thereason_noteinAfterJSON— the "why") ANDprofile.<new_status>(the canonical per-status transition row) audit events. Audit-feed consumers querying by(resource_type=pet_profile, resource_id=…)see a contiguous timeline regardless of forward vs reverse flow.
Manager write-back tools (Phase 1e)
update_pet
Patch fields on a pet record: name, species, breed, color/markings, sex, spayed/neutered, weight, date of birth, microchip ID. Pass only the fields you want to change; omitted fields are left alone. A no-op edit (every supplied value matches what's already on the row) does not write an audit row — keeps the per-pet audit history clean.
Manager identities are gated on property assignment: a manager who has
no profile for the pet at any of their assigned properties sees
CodeNotFound (not CodePermissionDenied) so the gate doesn't leak
existence across properties. Operators and admins skip the gate.
- Scope:
pets:write - Input: every field optional except
pet_id; seeinternal/mcp/schemas/update_pet.jsonfor the full list. - Output:
Pet(full DTO so the agent confirms what landed) - Side effects:
pet.updatedaudit row,BeforeJSON/AfterJSONcontaining only the fields that actually changed.
update_profile_manager_note
Set or clear the internal manager_note on a profile. The note is
not visible to the resident; it's a manager-only observation field
("resident on vacation until Friday"). Pass an empty string to clear.
The audit row fires every call — including no-op edits where the new value matches the old. Manager-note edits are rare enough that visibility wins over noise reduction; the audit trail shows the manager looked at this profile and confirmed the note.
- Scope:
profiles:write - Input:
{ "profile_id": "petprof_...", "note": "Resident on vacation until Friday." } - Output: refreshed
ProfileSummary - Side effects:
profile.manager_note_updatedaudit row with before/after diff.
notify_resident_about_profile
Send a templated email to the resident about their pet application.
The agent picks message_type and supplies a single custom_message;
everything else (greeting, subject, footer) is template-driven so the
agent cannot smuggle markup, links, or footer noise past the manager.
HTML in custom_message is escaped before rendering.
message_type |
Use | custom_message |
|---|---|---|
request_document |
Ask the resident to upload a specific document | required (the request) |
general_question |
Ask a clarifying question about the application | required (the question) |
status_check_in |
Proactive update with current status | optional (note alongside status) |
Unlike the auto-email side-effect of transition_profile→needs_info
(which is fail-soft because the state change is durable), this tool is
the agent's primary intent — send failures DO surface to the agent so
it can retry or fall back. The email.failed.notify_resident audit
row is written before the error returns so the failure is recoverable
from the audit log alone.
- Scope:
profiles:notify— deliberately separate fromprofiles:review. An integration can be granted "email residents" power without also granting "approve / reject" power. Example: a customer-success agent that follows up on stale applications. - Input:
{ "profile_id": "petprof_...", "message_type": "request_document | general_question | status_check_in", "custom_message": "Could you confirm whether Buddy has had a flea treatment in the last 30 days?" } - Output:
{ "profile_id", "recipient", "message_type", "sent": true } - Side effects: on success,
email.sent.notify_residentaudit row with the rendered recipient, template, andcustom_messageinAfterJSON. On failure,email.failed.notify_residentaudit row with the error string.
Wire codes
Tool failures map to JSON-RPC error codes (the error.code field on the
response):
| Code | Constant | Meaning |
|---|---|---|
-32602 |
CodeInvalidParams |
Input failed JSON-Schema validation |
-32000 |
CodePermissionDenied |
Identity is missing the per-tool scope |
-32001 |
CodeNotFound |
Resource does not exist or is not visible to this org |
-32002 |
CodeConflict |
State machine refuses the transition; concurrent write |
-32003 |
CodeValidation |
Input passes schema but fails domain rule (note required, document not ready, storage unconfigured) |
-32603 |
CodeInternalError |
Unexpected error |
CodeNotFound is also returned for cross-org access — we do not
distinguish "exists in another org" from "does not exist" so an attacker
cannot enumerate identifiers across tenants.
Example agent flow
A property manager asks Claude to triage the review queue:
User: Show me what's in the Acme Apartments review queue and approve anything where everything checks out.
Claude's tool sequence:
list_properties→ findsprop_...for Acme Apartmentslist_profileswithproperty_id=prop_...andstatus=submitted→ 4 profilesget_profilefor each → reads pet info, vaccination status, vet contact, bite history, attached documents- For 3 profiles where everything checks out:
transition_profilewithto_status=approvedand a one-linenote - For the 4th:
transition_profilewithto_status=needs_infoand anoteexplaining what the resident must add - Returns a summary: "Approved 3 profiles; sent the 4th back for a missing rabies record."
Every tool call lands in the audit table. The three approvals each
produce two rows: mcp.tool.transition_profile.success (transport-scoped)
and profile.approved (resource-scoped). A compliance officer reviewing
the audit feed for that property six months later sees the same history
they'd see if a human had clicked through the admin UI — only with the
API key ID and acting-user header attribution telling the story of the
agent.
Server-rendered widget tools (Phase 2.4)
Three read-only tools designed for the in-app chat surface.
The chat handler wraps each response in a fenced widget
block so the frontend renders a styled component
automatically. External MCP clients (Claude Desktop, custom
integrations) get the raw JSON response — the wrap is
chat-handler-specific. See docs/chat.md §
Server-rendered visuals
for the architecture rationale and the per-tool wire shapes.
All three carry profiles:read scope and auto-scope by
caller role: resident → own profiles; manager → assigned
properties; admin / operator+pin → org-wide. API-key
callers behave as admin within the org (the dispatcher
scope check is the authorisation; no per-row role gate).
list_review_queue
Returns a scoped queue of profiles needing attention.
- Scope:
profiles:read - Input:
{ "scope": "my_queue" | "all" | "needs_action" }(optional; defaultmy_queue) - Output:
{ "kind": "review-queue", "title", "rows": [...] }— per-row fields:profile_id,pet_name,pet_breed,resident_name,property_name,status,score,score_label,days_in_status,urgency,can_write - Sort: urgency desc → days_in_status desc (oldest highest within tier)
- Urgency tiers:
>14d high,7-14d medium,<7d low - Cap: 20 rows in v0.1 (no pagination — longer queues drop into
/admin/profilesfor the paginated view)
get_priority_item
Returns the single highest-priority profile in scope, for the briefing's lead callout. Reuses the same candidate set
- filter logic as
list_review_queueso the lead always appears in the queue list too.
- Scope:
profiles:read - Input:
{ "scope": "my_queue" | "all" | "needs_action" }(optional; defaultmy_queue) - Output:
{ "kind": "priority-item", "icon", "urgency", "title", "detail", "action_hint", "profile_id" }for a real lead, OR{ "kind": "priority-item", "icon": "✅", "urgency": "low", "title": "Queue clear", "queue_clear": true, ... }when no profile matches the scope - Icon mapping: 🔥 high, ⚠️ medium, 🟢 low, ✅ queue-clear
get_profile_summary
Returns the summary-card payload for a single profile — title, subtitle, score chip, status, plus highlights + concerns derived from screening fields.
- Scope:
profiles:read(also subject toCheckCanReadProfile: admin / operator+pin / manager-assigned-to-property / resident-on-profile) - Input:
{ "profile_id": "petprof_..." } - Output:
{ "kind": "profile-summary", "profile_id", "title", "subtitle", "score", "score_label", "status", "highlights": [...], "concerns": [...] } - Highlights are derived from positive screening fields (
vaccinations_current=true,house_trained=true, FHA flags, etc.). Concerns are derived from definite negatives, missing critical fields (no vet on file), and the outstanding manager note when present. - Unanswered fields stay SILENT — only explicit false values or resident absence notes count as concerns. Score breakdown intentionally omitted (drill-down lives in
explain_profile_score).
Phase 1e write-back example
A manager asks Claude to follow up on a stale application:
User: We approved Buddy a month ago but the vet record turned out to be expired. Reopen the application and ask the resident to upload a current rabies certificate.
Claude's tool sequence:
list_profilesfilter by status=approved + resident name → findspetprof_...for Buddyreopen_profilewithto_status=under_reviewand a 10+ charreason_note("Approval was based on vaccination paperwork that has since been determined to be expired.")notify_resident_about_profilewithmessage_type=request_documentand a custom message naming the rabies certificate
The audit feed now carries five rows for Buddy's profile:
profile.reopened resource_type=pet_profile resource_id=petprof_...
profile.under_review resource_type=pet_profile resource_id=petprof_...
mcp.tool.reopen_profile.success resource_id=reopen_profile
email.sent.notify_resident resource_type=pet_profile resource_id=petprof_...
mcp.tool.notify_resident_about_profile.success resource_id=notify_resident_about_profile
The pair on each domain action — profile.reopened plus profile.<status>,
email.sent.notify_resident plus mcp.tool.<name>.success — is the
audit-grade promise: a per-resource query (WHERE resource_type='pet_profile')
and a per-tool query (WHERE resource_type='mcp_tool') both reconstruct
the agent's session, with no gap between what landed and what the agent
did.
Demo dataset
Connect an agent to a deploy seeded with leashpass seed --demo --reset --seed-logs --yes
and the catalog has real, queryable answers for prompts like:
- "What pet screening profiles need my review at Acme Apartments East?"
→
list_profilesfiltered by property + status - "Who's been most active in reviews this month?"
→
audit:readaggregated byactor_user_idandaction="profile.under_review" - "Show me approved profiles with scores below 70."
→
list_profiles(status=approved) +get_profilefor the score field - "What's the audit trail for Buddy's approval?"
→
get_profile_auditfor Buddy's profile ID — returns the fulldraft_created → submitted → under_review → approvedarc the synthetic log lays down - "Are there any duplicate documents across applications?"
→
audit:readforaction="document.duplicate_detected"— the Bella/Buddy hash collision is in there by design - "What was the average time-to-approve at Acme East last month?"
→
list_profiles+ per-profile audit timestamps; the trail spans 6 months so "last month" returns a real cohort - "Has Aaron Bell logged in recently? Has he reviewed anything yet?"
→
get_user+audit:readfiltered by Aaron's user ID. Aaron is the deliberate "new hire we haven't trained yet" — login events only, no review actions.
The dataset is fully deterministic (PRNG seeded by SHA-256 of
"leashpass-demo-v1"), so a re-run after --reset produces
identical events and the same prompts return the same answers.
That makes the demo reproducible across machines and architectures.
See docs/seeding.md for the full inventory and
docs/mcp-demo.md for the step-by-step Claude Desktop
walkthrough.
What is NOT exposed via MCP
By design, the v1 catalog is read + workflow only. The following are NOT available as MCP tools today:
- Document upload — Phase 1d.5 once we have practical experience with how Claude Desktop serializes attached files.
- Entity creation (
create_pet,create_profile,create_resident) — needs UX design for what defaults the agent chooses. - Operator-level admin (creating organizations, plan changes)
- Server configuration (encryption keys, rate-limit policy)
- API key minting — agents can use keys but cannot create them
- Direct SQL or migration access
If you have a use case that requires crossing one of these boundaries, open an issue describing the workflow and we'll discuss the right shape for it.
Security model
- Scoped keys. The same scope vocabulary as REST applies. A key with
only
profiles:readcannot transition profiles, regardless of what the agent asks. Themcp:connectscope gates SSE connection establishment; per-tool scopes gate individual tool calls. - Per-key rate limits. The same buckets as REST apply to MCP tool calls.
- No long-lived credentials in the channel. The
Authorizationheader is sent on every tool-call HTTP request behind the SSE wrapper, never cached client-side beyond the connection lifetime. - Audit on every call. There is no "read-only no-audit" mode. Every read is logged at the same fidelity as a write; workflow tools land two rows so the resource-scoped audit feed is complete.
- Cross-org isolation. Every tool query is scoped by the API key's
organization_id. The integration tests ininternal/server/handlers_mcp_test.goexercise this end-to-end: org A's key cannot read org B's resources via any tool, even by guessing IDs.
Roadmap
| Milestone | Phase | Status |
|---|---|---|
| MCP server skeleton | 1d | ✓ shipped |
| Read-only tool catalog | 1d | ✓ shipped |
| Workflow / transition tool | 1d | ✓ shipped |
| Multi-org key isolation | 1d | ✓ shipped |
| Dual audit events on writes | 1d | ✓ shipped |
Hosted MCP at demo.leashpass.com |
1d | in progress |
Manager write-back tools (update_pet, update_profile_manager_note, reopen_profile, notify_resident_about_profile) |
1e | ✓ shipped |
Auto-email on needs_info transitions |
1e | ✓ shipped |
profiles:notify scope (split from profiles:review) |
1e | ✓ shipped |
| Document upload via MCP | 1d.5 | planned |
| Entity creation tools | 1d.5 | planned |
| Streamable-HTTP transport (2025-03-26) | post-1.0 | planned |
For status updates, follow the milestones page on GitHub.
See also
docs/mcp-demo.md— Claude Desktop walkthroughdocs/api.md— REST API reference (what MCP tools wrap)docs/auth.md— scope vocabulary and authenticationdocs/adr/0010-mcp-protocol-handroll.md— why we hand-rolled the transportPROJECT_PLAN.md§5.2 — agent-friendly API design principles