Skip to main content
Glama
colecantu904

virtuous-mcp

by colecantu904
README.md
# Virtuous CRM+ MCP Server

An [MCP](https://modelcontextprotocol.io/) server, built with
[FastMCP](https://github.com/jlowin/fastmcp), that lets an AI assistant work
with [Virtuous CRM+](https://docs.virtuoussoftware.com/): query and read data
freely, and—**only with explicit user confirmation**—create, update, archive,
or delete data.

It provides **complete coverage of the entire Virtuous API** (all 291
endpoints across 39 resource groups) through a small set of convenience tools
plus a generic discovery + call layer, so any endpoint can be reached without
needing a separate tool per endpoint.

## Safety model: reads are free, writes require confirmation

Reading (querying, searching, looking up, listing reference data) runs freely.

Every tool that **changes** data is "mutating" and is guarded in three layers:

1. **Instructions** — the server and each mutating tool tell the model it must
   describe the exact change and get explicit user approval *before* acting.
2. **`confirm` flag** — every mutating tool takes `confirm` (default `false`).
   With `confirm=false` the tool makes **no** API call and returns a *preview*
   of what it would do, so the model can show the user and ask.
3. **Client backstop** — the HTTP client raises `ConfirmationRequired` if a
   write is ever attempted without explicit confirmation, so an accidental
   `confirm=true` is the only way a write can happen.

A request is classified as a **read** if it's a `GET`, or a `POST` to a
`/Query`, `/QueryOptions`, `/Search`, `/Find`, or `/Proximity` path. Everything
else is a write.

## Operational protocols

The HTTP layer is aligned with Virtuous's documented operational behavior:

- **Connection pooling** — a single `httpx.AsyncClient` is reused for the life
  of the process (per base URL), so TLS/keep-alive connections are reused
  instead of re-established on every call.
- **Rate limits** — Virtuous enforces an **org-wide** budget (documented at
  **5,000 requests/hour**) shared by every API key/integration in the org, and
  returns `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
  on every response. The client records the latest values; call
  `get_rate_limit_status` to inspect remaining budget.
- **Retries + backoff** — transient `429` and `5xx` responses are retried (up to
  3 times). `429` waits honor `Retry-After` / `X-RateLimit-Reset`; otherwise an
  exponential backoff with jitter is used.
- **Pagination** — query endpoints cap at **1000 records/call**; `query_all`
  auto-pages (with a hard ceiling) so you don't manually loop `skip`/`take`.
- **Bulk writes** — `create_batch` posts many contacts/gifts in one request via
  the recommended batch endpoints, conserving the shared rate budget.

## Tools

### Full-API discovery + generic call

The entire API is reachable through these. Use discovery to find the exact
method + path, then `call_endpoint` to invoke it.

| Tool | Purpose |
| --- | --- |
| `list_resources()` | List all 39 resource groups and their read/write endpoint counts. |
| `list_endpoints(resource, search, only)` | Discover any endpoint (filter by resource, text search, or `reads`/`writes`). |
| `describe_endpoint(method, path)` | Full metadata + parameters for one endpoint. |
| `call_endpoint(method, path, path_params, query_params, body, confirm)` | Invoke **any** endpoint. Reads run freely; writes obey the confirmation gate. |

`call_endpoint` resolves `:placeholders` in the path from `path_params` (e.g.
`/api/Contact/:contactId` + `{"contactId": 123}`), and works even for endpoints
not in the bundled registry.

### Read tools (no confirmation)

| Tool | Purpose |
| --- | --- |
| `list_query_object_types` | List queryable object types + reference-data keys. |
| `get_query_options(object_type)` | Discover queryable fields, data types, and allowed operators for an object. |
| `query_records(object_type, groups, sort_by, descending, skip, take, full_detail)` | Run a filtered bulk query (single page). |
| `query_all(object_type, groups, sort_by, descending, max_records, page_size, full_detail)` | Auto-paginate a query up to `max_records` (caps pages; reports rate-limit budget). |
| `get_record(object_type, record_id)` | Fetch a single record by id. |
| `find_contact(email \| reference_source+reference_id)` | Look up one contact. |
| `search_contacts(search, skip, take)` | Fuzzy free-text contact search. |
| `get_gifts_by_contact(contact_id)` | All gifts for a contact. |
| `get_contact_notes(contact_id, important_only)` | Notes for a contact. |
| `get_individuals_by_contact(contact_id)` | Individuals that make up a contact. |
| `get_reference_data(key)` | Lookup lists: contact/gift/project/task types, tags, custom fields, org groups, etc. |
| `get_current_context()` | Current organization + the API key's permissions. |
| `get_rate_limit_status()` | Latest observed rate-limit headers (remaining org-wide budget + reset time). |
| `read_request(path, params)` | Escape hatch for arbitrary read-only `GET` calls. |
| `read_paged_request(path, params, max_records, page_size)` | Auto-page read-only `GET` endpoints that use `skip`/`take` and return a `list`/`total` envelope. |

### Write tools (MUTATING — require `confirm=true` after explicit user approval)

| Tool | Purpose |
| --- | --- |
| `create_transaction(kind, body, confirm)` | Recommended way to import a single Contact or Gift (matched/validated). |
| `create_batch(kind, body, confirm)` | Bulk-import many Contacts or Gifts in one request (rate-limit-friendly). |
| `create_record(object_type, body, confirm)` | Create a record (e.g. ContactNote, ContactTag, Task, Relationship). |
| `update_record(object_type, record_id, body, confirm)` | Update a record (PUT). |
| `archive_record(object_type, record_id, unarchive, confirm)` | Archive/unarchive a record. |
| `delete_record(object_type, record_id, confirm)` | **Destructive** delete. |
| `write_request(method, path, body, confirm)` | Escape hatch for any other write (cancel recurring gift, write off pledge, send email, toggle webhook, etc.). |

With `confirm` omitted/`false`, write tools (and `call_endpoint` on a write
endpoint) return a `confirmation_required` preview and change nothing.

> Note: `call_endpoint` is the universal way to reach any write endpoint and is
> subject to the same confirmation gate. The dedicated write tools above are
> just ergonomic shortcuts for the most common operations.

### How queries work

A query body is made of `groups`. Conditions **within** a group are AND-ed;
separate groups are OR-ed. Each condition is:

```json
{ "parameter": "<field name>", "operator": "<operator>", "value": "<value>" }
```

Use `get_query_options` to get the exact `parameter` and `operator` strings for
an object. Example: contacts created on/after 2024-01-01, sorted by id desc:

```json
{
  "object_type": "Contact",
  "groups": [
    { "conditions": [
      { "parameter": "Create Date", "operator": "GreaterThanOrEqual", "value": "01/01/2024" }
    ] }
  ],
  "sort_by": "Id",
  "descending": true,
  "take": 100
}
```

Query endpoints return at most **1000** records per call; use `skip`/`take` to
page manually, or `query_all` to auto-paginate up to a `max_records` ceiling.
For non-query `GET` endpoints that expose the same `skip`/`take` pattern (for
example contacts by tag or organization-group members), use
`read_paged_request`.

### Tasks & reminders (non-obvious gotchas)

These are surfaced at runtime via `describe_endpoint` (`notes` + `body_params`)
and in the server instructions, but documented here too:

- **Create a task** with `POST /api/Task`. The assignee field is **`ownerEmail`**
  (the user's email) — **not** `owner`, `ownerId`, or `assignedTo`. A wrong key is
  silently ignored and the task is created **unassigned**, and the success response
  does **not** echo `ownerEmail` back (its absence is not a failure).
- **Tasks have no update or delete endpoint.** To remove/resolve a task, use the
  Reminder endpoints: `PUT /api/Reminder/Dismissed/{id}` (dismiss ≈ delete) or
  `PUT /api/Reminder/Completed/{id}` (mark resolved). There is **no un-dismiss /
  reactivate** endpoint via the API (UI only).
- **To check if tasks are dismissed/resolved**, query `Task` with the `Resolved`
  filter (`IsTrue` = dismissed/completed, `IsFalse` = active). The query result does
  not expose the owner or an explicit resolved field; the `Assigned User` filter
  expects an internal user id (not an email), and no users-list endpoint is exposed.

## Setup

1. Get a Virtuous API key: in Virtuous, **Settings → All Settings →
   Connectivity → Application Keys → Create an Application Key**.
2. Copy `.env.example` to `.env` and set `VIRTUOUS_API_KEY`.

### Install `uv`

This project is managed with [`uv`](https://docs.astral.sh/uv/), a fast Python
package and project manager. Install it once in your environment:

```bash
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
```

```bash
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

`uv` is also available via other package managers if you prefer:

```bash
# Homebrew (macOS)
brew install uv

# pipx
pipx install uv
```

After installing, restart your shell (or follow the printed instructions) so the
`uv` command is on your `PATH`, then verify:

```bash
uv --version
```

`uv` manages the virtual environment and can even provision a compatible Python
(3.10+) for you, so you don't need to set up Python separately.

### Install dependencies

```bash
uv sync
```

This creates a virtual environment and installs the exact dependencies pinned in
`uv.lock`.

## Run

```bash
VIRTUOUS_API_KEY=your_key uv run virtuous-mcp
```

The server speaks MCP over stdio.

## Use with an MCP client

Every client below uses the **same** server entry — only the file it lives in
(and the surrounding scope) changes:

```json
{
  "mcpServers": {
    "virtuous-mcp": {
      "command": "uv",
      "args": ["run", "--directory", "/Users/cole.j.cantu/Programs/custom-mcp/virtuous-mcp", "virtuous-mcp"],
      "env": { "VIRTUOUS_API_KEY": "your_api_key_here" }
    }
  }
}
```

### Cursor (global / all projects)

Add it to your **global** Cursor config so it's available in every project:

- **macOS / Linux:** `~/.cursor/mcp.json`
- **Windows:** `%USERPROFILE%\.cursor\mcp.json`

Create the file if it doesn't exist and paste the JSON block above (top-level
`mcpServers` key). For a single project instead, use `.cursor/mcp.json` in that
project's root — project config takes precedence over global if both define a
server with the same name. Reload Cursor (or toggle the server in
**Settings → Tools & Integrations → MCP**) after saving.

### Claude Code (user scope / all projects)

User scope makes the server available to you across **all** projects. Two ways:

**CLI (recommended):**

```bash
claude mcp add virtuous-mcp \
  --scope user \
  --env VIRTUOUS_API_KEY=your_api_key_here \
  -- uv run --directory /Users/cole.j.cantu/Programs/custom-mcp/virtuous-mcp virtuous-mcp
```

`--scope user` writes to `~/.claude.json` under the **top-level** `mcpServers`
key. (Other scopes: `project` → `.mcp.json` in the repo root, shared with
everyone who clones it; `local` (default) → your private entry for the current
project only.) Everything after `--` is the command Claude Code runs to launch
the server.

**Edit `~/.claude.json` directly:**

Add the server under the **top-level** `mcpServers` object (this is what makes
it user-scoped — not nested under a specific project's entry):

```json
{
  "mcpServers": {
    "virtuous-mcp": {
      "command": "uv",
      "args": ["run", "--directory", "/Users/cole.j.cantu/Programs/custom-mcp/virtuous-mcp", "virtuous-mcp"],
      "env": { "VIRTUOUS_API_KEY": "your_api_key_here" }
    }
  }
}
```

`~/.claude.json` also holds other Claude Code settings, so merge into the
existing `mcpServers` object rather than overwriting the file. Restart your
Claude Code session afterward so it re-reads the config.

### Claude Code scope reference

| Scope | Where it's stored | Available to |
| --- | --- | --- |
| `local` (default) | `~/.claude.json`, under this project's entry | You, this project only |
| `project` | `.mcp.json` in project root | Anyone who clones the repo |
| `user` | `~/.claude.json`, top-level `mcpServers` | You, all projects |

### Claude Desktop

Same JSON block in Claude Desktop's `claude_desktop_config.json` (under
`mcpServers`).

## Configuration

| Env var | Required | Default | Description |
| --- | --- | --- | --- |
| `VIRTUOUS_API_KEY` | yes | — | Bearer API key / Application Key. |
| `VIRTUOUS_BASE_URL` | no | `https://api.virtuoussoftware.com` | API base URL. |

TDQS

A3.9/5.0

Scored across 26 tools

Disambiguation4/5

Tools generally have distinct purposes, but query_all and query_records overlap, and generic escape hatches (call_endpoint, read_request, write_request) can create ambiguity with dedicated tools. Descriptions help clarify, but the potential for misselection exists.

Naming Consistency5/5

All tool names follow a consistent 'verb_noun' pattern with underscore separation. Verbs are varied but predictable, and naming is uniform throughout the set.

Tool Count4/5

26 tools is on the higher side but appropriate for a comprehensive CRM API server. Each tool serves a clear purpose, and the count is justified by the scope of the API.

Completeness5/5

The tool surface covers CRUD operations, querying, bulk imports, metadata discovery, rate limits, and escape hatches for missing endpoints. No obvious gaps; the generic tools ensure full API coverage.

Maintenance

ActivityStale
ResponsivenessNo issues