A reference implementation of a schema-driven MCP server for ConnectWise Manage (CWM). Built around a three-layer architecture where the data shape — not the function signatures — is the single source of truth.
The integration target is CWM, but the pattern is reusable for any vendor API. The whole point of the project is the architecture.
Most MCP server codebases I've seen are behavior-driven: each tool function decides what it returns, helpers are optional, and over time the shapes drift. New tools copy the "almost right" pattern from an existing tool, helper functions get duplicated, field names get subtly renamed, and bugs hide in the gaps between functions. The bigger the surface gets, the more it drifts.
This repo demonstrates a different approach: the data shape is the contract. Pydantic models in domain/models.py define what every entity looks like, exactly once. Every tool function references and produces those models. When CWM changes a field, you update one method on the schema and every tool that returns that entity is automatically corrected.
For the full architectural reasoning — including the trade-offs, when to use this pattern, and when not to — see docs/schema-vs-behavior-driven-design.md.
15 MCP tools exposing CWM's most-used surfaces:
Reads: ticket_get, ticket_search,
company_get, company_search,
contact_get, contact_search,
configuration_get, configuration_search,
project_get, project_search,
time_entry_search
Writes: ticket_create, ticket_update,
ticket_add_note, ticket_draft_reply
Every read tool accepts include=[<keys>] for depth-on-demand. Every search returns the same SearchEnvelope shape. Every response carries a _meta field describing what was applied and what was available.
CWM has two distinct ticket types under the hood:
- Service tickets at
/service/tickets— your standard support tickets - Project tickets at
/project/tickets— tickets attached to a project (PM work)
Most CWM integrations expose only service tickets and pretend project tickets don't exist. This means a query like "find all tickets assigned to Devin" silently misses every project ticket he owns.
This MCP unifies both under a single Ticket model with a kind: "service" | "project" discriminator. ticket_search queries both endpoints in parallel and merges. ticket_get auto-discriminates by trying service first, then project on 404.
Service-only filters (board_name, type, subtype) implicitly narrow to service kind. Project-only filters (project_id) narrow to project kind. The agent calling the tool doesn't need to think about which endpoint owns the data.
Instead of separate get, get_full, and get_with_X tools per entity, every read tool accepts an include=[...] list. Valid keys are defined per entity in domain/models.py:
# Ticket includes:
"contact", "company", "owner", "notes", "time_entries", "tasks",
"documents", "changelog", "configurations" (service only),
"project" (project only), "all"
# Company includes:
"contacts", "agreements", "configurations", "recent_tickets", "all"
# Contact includes:
"company", "recent_tickets", "all"
# Configuration includes:
"company", "contact", "tickets_referencing", "all"
# Project includes:
"company", "manager", "tickets", "phases", "all"
# TimeEntry includes:
"ticket", "member", "company", "all"Pass include=["all"] when you don't know what's available. Invalid keys fail loudly with the valid list named — agents self-correct in one retry. Every response carries _meta.applied_includes and _meta.available_includes so agents discover the vocabulary without needing external docs.
agent → mcp_server.py (tool surface, ~750 LOC)
└→ domain/ (data layer, ~2,500 LOC)
├─ models.py ← pydantic schemas + from_cwm_* translation
├─ fetchers.py ← typed async functions + include orchestration
├─ envelopes.py ← SearchEnvelope[T] generic
├─ cwm_client.py ← HTTP/auth/retry
└─ _helpers.py ← extraction primitives
└→ CWM REST API
Each layer has exactly one job:
- Tool surface routes calls and serializes responses. Knows nothing about CWM internals.
- Models define the schema and own translation from raw CWM shape to the canonical shape.
- Fetchers know about both CWM endpoints and the models. They orchestrate include= sub-fetches in parallel.
- CWM client owns HTTP, auth, retry, and pagination.
- CWM REST API is just an HTTP service at the bottom.
Troubleshooting follows the layer model:
- "The agent saw the wrong field" → look in
models.py - "The data translation is off" → look in
models.pyfrom_cwm_*methods - "CWM returned a 4xx" → look in
cwm_client.py - "include='X' returned nothing" → look in
fetchers.py_apply_*_includes
pip install -e .
# or, if you use uv:
uv syncRequires Python 3.12+.
Copy .env.example to .env and fill in your CWM credentials:
cp .env.example .env
# Edit .env with your CWM company short code + API keysRequired values:
CWM_COMPANY_ID— your CWM company short code (e.g.acmemsp)CWM_PUBLIC_KEYandCWM_PRIVATE_KEY— generate from a CWM API Member's keys tabCWM_CLIENT_ID— your integration's client_id GUID (provided by ConnectWise on integration registration)CWM_TENANT— used only for building user-facing ticket URLs (matches thecompanyName=query parameter in CWM web URLs)
Optional:
CWM_BASE_URL— defaults to NA cloud; override for EU/AU/stagingMCP_HOST/MCP_PORT— defaults to127.0.0.1:8101DEFAULT_TIMEZONE— used byticket_draft_replyfor embedding time context
python mcp_server.pyThen point an MCP client at http://127.0.0.1:8101/mcp.
curl http://127.0.0.1:8101/health
# {"status":"ok","service":"schema-driven-cwm-mcp","version":"1.0.0"}This reference implementation runs without auth. For production:
-
Wire your auth. FastMCP supports OAuth 2.0 token verification via
auth=AuthSettings(...)andtoken_verifier=YourTokenVerifier(). The original implementation this is derived from used Microsoft Entra (Azure AD) OAuth. Pick whatever fits your environment. -
Replace env-var secrets with a real secret manager. The
_load_secrethelper indomain/cwm_client.pyis the integration point. Swap in calls to AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, GCP Secret Manager, etc. -
Run behind a reverse proxy. Nginx, Caddy, or a managed gateway. Terminate TLS there. The MCP server binds to localhost by default for this reason.
-
Add observability. Wire your logging to your aggregator. The audit log line in
mcp.tool_auditis a good hook for per-tool metrics. Addjournalctl/systemdintegration if you're running it as a service. -
Bound concurrency on includes. The current code fans out parallel CWM calls when
include=is set. For agents that aggressively passinclude=['all']on a large search, this can chew through CWM's rate budget. Consider addingasyncio.Semaphorebounds infetchers.py.
The original implementation used dotted noun-first names like ticket.get, company.search, etc. — mirroring class-method syntax. Claude's MCP client validates tool names against ^[a-zA-Z0-9_-]{1,64}$ — dots are not allowed, and a violation rejects the entire tool list rather than just the offending tool.
Underscores preserve the noun-first family grouping (ticket_* clusters visually just like ticket.* would have) without tripping the validator. Use underscores or hyphens; never dots.
When this surface replaced an older verb-noun MCP it derives from (get_ticket, search_tickets, etc.), no compatibility aliases were kept. Clean cuts are easier to reason about than long migration windows.
This works if you're confident no stored agent runbooks or skills reference the old names. If you do have external consumers, you'd want to ship both surfaces during a transition window.
Pydantic enforces shapes at parse time, but only when you actually use it. The repo's discipline is:
- Every CWM response goes through a
Model.from_cwm_*()method - Every tool returns
model.model_dump(by_alias=True, exclude_none=True) - No tool writes ad-hoc dicts to the wire
If your team can't hold that discipline forever, you'll drift back into behavior-driven territory even with pydantic available. Tooling helps; people enforce.
Honest about limitations:
- No write surface for time entries. CWM's API doesn't allow API-member credentials to author time entries. This is a CWM constraint, not a code limitation.
tickets_referencingon Configuration is best-effort. CWM doesn't expose a clean inverse query for "tickets that reference this config." The current implementation falls back to a summary-search workaround that's imperfect.- No Documents API. ITGlue or another DMS is usually the document layer at MSPs; this MCP doesn't try to be one.
The full architectural reasoning — behavior-driven vs schema-driven, when each pattern fits, real failure modes, propagation properties, the principle from Linus Torvalds about data structures being more important than control flow — lives at docs/schema-vs-behavior-driven-design.md.
Worth reading in full if you're considering this pattern for your own MCP server.
MIT. See LICENSE.
This implementation derives from production MCP work at an MSP. The schema-driven redesign was prompted by a real failure: an agent attempted to find every ticket assigned to a specific tech, the MCP returned zero rows because the project module was unexposed, and the resulting retrospective made it obvious that the underlying architecture — not any single tool — was the problem.
The fix was the redesign in this repo. After ~3,300 lines of new code replaced ~13,500 lines of behavior-driven code, the same agent workflow returned correct results across both ticket modules with one tool call. The architecture wasn't faster to write — it was faster to maintain, faster to extend, and structurally prevented the class of bug that motivated it.
That's the pattern. It's not novel; the schema-driven principle has been around for decades. But applying it deliberately to an MCP server — where the consumer is an LLM agent that needs predictable shapes to reason cleanly — produced a noticeably better result than the behavior-driven default.