MCP Surface
Audience: Engineers integrating an AI agent with the server's tool surface.
What you will accomplish: Understand every tool, resource, and prompt the MCP server exposes — their inputs, outputs, risk tiers, and idempotency contracts.
Prerequisites: Read Architecture first.
Estimated time: ~10 minutes.
The MCP server (cam.mcp_server) publishes the full operational surface as tools (actions), resources (read-only context), and prompts (reusable templates). Every primitive is self-describing via Pydantic v2 schemas — the client learns the interface automatically.
What is the MCP surface?
The MCP (Model Context Protocol) surface is the complete set of operations an AI agent can perform. It has three parts:
- Tools — actions the agent can take (read data, create records, draft emails, run workflows)
- Resources — read-only context the agent can pull on demand (matter details, templates, deadline rules)
- Prompts — reusable reasoning templates for common tasks (intake interview, privilege review)
Every tool is tagged with a risk tier (read, write-confirm, or gated-human) that determines whether human approval is needed before execution. The client learns the interface automatically from Pydantic v2 JSON schemas — no manual integration required.
Tools
Tools are thin wrappers: validate input → call core service → write audit → return structured output. They carry no business logic of their own.
Matter management
| Tool | Input | Output | Risk tier |
|---|---|---|---|
matter.get | id: str | Matter | read |
matter.search | query: MatterSearchQuery | list[Matter] | read |
matter.create | data: MatterDraft | Matter | write (confirm) |
Contact management
| Tool | Input | Output | Risk tier |
|---|---|---|---|
contact.upsert | contact: Contact | Contact | write (confirm) |
contact.find | query: str | list[Contact] | read |
Document operations
| Tool | Input | Output | Risk tier |
|---|---|---|---|
document.generate | matter_id: str, template_name: str, data_context?: dict, doc_key?: str, idempotency_key?: str, render_pdf: bool = true | GenerationResult | write (confirm) |
document.route | document_id: str, recipient_id: str, intent: RoutingIntent | RoutingResult | write (confirm) / gated (human) |
document.extract | input_ref: str, content_kind: "pdf" | "image" | "email_body", mapping_profile?: str, threshold?: float, structuring: bool = true, async_ok: bool = true | ExtractionProposal | {run_id, status_resource} | read |
Communication
| Tool | Input | Output | Risk tier |
|---|---|---|---|
email.draft | matter_id: str, template: str, context: dict | Communication | draft only |
email.send | draft_id: str, idem_key: str | str (message_id) | gated (human) |
email.send is the only tool in the default set that carries the gated (human) tier. No approval token can override this — the gate is a safety invariant. Drafting is unrestricted; sending requires explicit human approval.
Deadline management
| Tool | Input | Output | Risk tier |
|---|---|---|---|
deadline.compute | rule_id: str, jurisdiction: str, trigger_inputs: dict, practice_area?: str, as_of?: datetime | DeadlineComputeResult | read |
deadline.schedule | matter_id: str, rule_id: str, jurisdiction: str, trigger_inputs: dict, name: str, idempotency_key: str, practice_area?: str | ScheduledDeadline | write (confirm) |
Workflow control
| Tool | Input | Output | Risk tier |
|---|---|---|---|
workflow.run | workflow: str, context: dict, trigger?: str, idem_key?: str | WorkflowRun | varies by workflow |
workflow.status | run_id: str | WorkflowRunStatus | read |
workflow.approve | `run_id: str, token: str, decision: "approve" | "reject"` | ApprovalDecision |
QC & Forms
| Tool | Input | Output | Risk tier |
|---|---|---|---|
qc.verify | packet: VerificationPacket, checks?: list[str] | QCReport | read (gate input) |
form.prefill | matter_id: str, form_id: str, data_context?: dict, emit: "fields_only" | "document" | PrefillResult | GenerationResult | read (emit="fields_only") / write (confirm) (emit="document") |
Intake
| Tool | Input | Output | Risk tier |
|---|---|---|---|
intake.run | lead: LeadPayload, idem_key?: str | IntakeResult | composite (confirm) |
Resources
Resources are read-only context the agent can pull on demand. They return structured data, not natural language.
| Resource | URI pattern | Returns |
|---|---|---|
| Matter view | matter://{id} | Full matter (contacts, docs, deadlines, comms) |
| Template | template://{name} | Template spec + required variables |
| Deadline rules | deadline-rules://{jurisdiction} | Active rule sets for jurisdiction |
| Calendar upcoming | calendar://upcoming?window={days} | Deadlines/tasks due in window |
| Audit log slice | audit://{run_id} | Audit records for a workflow run |
| Connector health | health://connectors | Circuit state + last error per connector |
Prompts
Prompts are reusable, versioned prompt templates for common reasoning tasks.
| Prompt | Purpose | Version |
|---|---|---|
intake_interview | Structured client-intake questioning | v1 |
status_update_email | On-brand status update from matter delta | v1 |
qc_checklist | Verification reasoning scaffold | v1 |
extraction_schema | Guide LLM extraction into domain model | v1 |
privilege_review | Decision scaffold for privilege classification | v1 |
Each prompt carries a version hash. The workflow engine records the prompt version on every run that uses it, so output quality can be traced back to the exact prompt text.
Schema self-description
Every tool's input and output is a Pydantic v2 model. The MCP server automatically generates JSON Schema from these models, so the client knows:
- Required vs optional fields
- Field types and constraints
- Enum values
- Nested object shapes
Example: matter.create input schema (excerpt)
{
"title": "MatterDraft",
"type": "object",
"required": ["reference", "title", "client", "status"],
"properties": {
"reference": { "type": "string", "description": "Firm-assigned matter reference" },
"title": { "type": "string", "description": "Short descriptive title" },
"client": { "$ref": "#/definitions/Contact" },
"status": { "type": "string" },
"practice_area": { "type": "string", "enum": ["family-based", "employment-based", "other"] },
"responsible": { "type": "string", "description": "Attorney id or name" }
}
}Tool risk classification
The server tags every tool with a risk_tier enum. The client (or a policy layer) can use this to decide whether to invoke the tool automatically or require human confirmation.
class RiskTier(str, Enum):
READ = "read" # No side effects
WRITE_CONFIRM = "write_confirm" # Reversible, one-click confirm
GATED_HUMAN = "gated_human" # External/irreversible, explicit approvalIdempotency key contract
Every write tool accepts an optional idem_key. If provided:
- The engine checks the IdempotencyStore (Redis-backed) for a previous execution
- Hit → returns the stored output without re-effect
- Miss → executes, stores the output, and returns
This gives exactly-once semantics even under at-least-once delivery.
Example: calling intake.run
uv run python -c " from cam.core.workflows.intake import tool_intake_run from cam.core.workflows.intake.types import LeadPayload import asyncio result = asyncio.run(tool_intake_run( lead=LeadPayload(channel=\"web_form\", raw={\"email\": \"client@example.com\", \"name\": \"Jane Doe\"}), idem_key=\"intake:web:2026-06-01:jane-doe\" )) print(result) "Expected output:
{
"run_id": "run_abc123",
"status": "awaiting_approval",
"steps_completed": ["parse_lead", "dedupe_contact", "create_contact", "create_matter", "compute_deadlines", "open_tasks", "draft_welcome"],
"gate": {
"step": "GATE:human_review",
"channels": ["mcp", "web", "email"],
"ttl_seconds": 86400
},
"idempotency_key": "intake:web:2026-06-01:jane-doe"
}The run is parked at awaiting_approval. The welcome email will not send until an approver calls workflow.approve with a valid token.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Calling email.send without pre-drafting | The tool requires a draft_id; direct send is not possible | Always call email.draft first, then email.send with the draft ID |
Omitting idem_key on write tools | Retries may double-create or double-send | Always provide an idempotency key on write and gated tools |
Assuming workflow.approve can override privilege gates | The privilege gate is checked before the approval gate and cannot be bypassed | Verify document privilege status before attempting approval |
Verification
After reading this page, verify your understanding:
- Name the three risk tiers and which ones require human involvement
- Explain what happens when you provide the same
idem_keytwice tomatter.create - List the three approval channels available for gated tools
Next steps
- Workflow Engine — How tools are composed into durable multi-step processes
- Client Intake Workflow — End-to-end example using
intake.run - Connector Layer — How tools reach external systems
Last updated: 2026-06-01