Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

schema-driven-cwm-mcp

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.


Why this exists

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.


What's in the box

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.


The folded ticket model

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.


The include= mechanism

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.


Architecture (3 layers)

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.py from_cwm_* methods
  • "CWM returned a 4xx" → look in cwm_client.py
  • "include='X' returned nothing" → look in fetchers.py _apply_*_includes

Quick start

1. Install

pip install -e .
# or, if you use uv:
uv sync

Requires Python 3.12+.

2. Configure

Copy .env.example to .env and fill in your CWM credentials:

cp .env.example .env
# Edit .env with your CWM company short code + API keys

Required values:

  • CWM_COMPANY_ID — your CWM company short code (e.g. acmemsp)
  • CWM_PUBLIC_KEY and CWM_PRIVATE_KEY — generate from a CWM API Member's keys tab
  • CWM_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 the companyName= query parameter in CWM web URLs)

Optional:

  • CWM_BASE_URL — defaults to NA cloud; override for EU/AU/staging
  • MCP_HOST / MCP_PORT — defaults to 127.0.0.1:8101
  • DEFAULT_TIMEZONE — used by ticket_draft_reply for embedding time context

3. Run

python mcp_server.py

Then point an MCP client at http://127.0.0.1:8101/mcp.

4. Health check

curl http://127.0.0.1:8101/health
# {"status":"ok","service":"schema-driven-cwm-mcp","version":"1.0.0"}

Production deployment notes

This reference implementation runs without auth. For production:

  1. Wire your auth. FastMCP supports OAuth 2.0 token verification via auth=AuthSettings(...) and token_verifier=YourTokenVerifier(). The original implementation this is derived from used Microsoft Entra (Azure AD) OAuth. Pick whatever fits your environment.

  2. Replace env-var secrets with a real secret manager. The _load_secret helper in domain/cwm_client.py is the integration point. Swap in calls to AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, GCP Secret Manager, etc.

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

  4. Add observability. Wire your logging to your aggregator. The audit log line in mcp.tool_audit is a good hook for per-tool metrics. Add journalctl/systemd integration if you're running it as a service.

  5. Bound concurrency on includes. The current code fans out parallel CWM calls when include= is set. For agents that aggressively pass include=['all'] on a large search, this can chew through CWM's rate budget. Consider adding asyncio.Semaphore bounds in fetchers.py.


Design decisions worth noting

Tool naming: underscores, not dots

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.

No backward-compat shims

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.

Schema enforcement vs validation discipline

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.

What's missing

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_referencing on 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.

Architecture deep dive

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.


License

MIT. See LICENSE.


Background

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.

About

Reference implementation of a schema-driven MCP server for ConnectWise Manage. Three-layer architecture with pydantic models as the single source of truth for response shape.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages