Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ versions adhere to [Semantic Versioning](https://semver.org).

### Added

- **`delete_tag_definition` (write, destructive, confirm-gated).**
Deletes a tag DEFINITION from an entity type's tag namespace
(parties / opportunities / kases), removing it from every record
that shared it — distinct from `remove_tag_by_id`, which only
detaches a tag from one record and leaves the definition intact.
Closes the "stranded test / typo tag definitions can't be cleaned
up through the connector" gap. Endpoint verified empirically
(`scripts/wire-trace-v167.ts`): `DELETE /<entity>/tags/{id}` → 204,
with a follow-up read confirming tenant-wide removal. Idempotent on
404; mirrors the other `delete_*` tools' confirm gate and
destructive annotation. Tool catalog 87 → 88; destructive-hinted
tools 7 → 8.

- **`GET /` now serves a small HTML landing page** — a short
human-readable body pointing at `/mcp` and the GitHub repo, plus
`<link rel="icon" type="image/svg+xml" href="/icon.svg">` and
Expand Down Expand Up @@ -110,6 +123,14 @@ versions adhere to [Semantic Versioning](https://semver.org).
person-partyId no-op. Same `ZZZ-V166-*` test record tagging and
full cleanup pattern as v164 / v165.

- **NOTES-ON-CAPSULE-API.md §33 (new section)** records two
endpoint-existence findings from `scripts/wire-trace-v167.ts`:
`GET /tracks` → 405 (no tenant-wide track-instance list, so a
`list_tracks` tool is NOT buildable — orphan tracks per §25 stay
reachable only by known id), and `DELETE /<entity>/tags/{id}` → 204
(tag definitions ARE deletable tenant-wide, now exposed as
`delete_tag_definition`). The probe ships for re-runnability.

## [1.6.5] — 2026-05-25

Patch release on top of v1.6.3. Combines two waves of consistency
Expand Down
2 changes: 1 addition & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ In Claude.ai admin → **Settings → Connectors → Custom Connectors → Add c
| Client ID | `MCP_OAUTH_CLIENT_ID` you set during deploy |
| Client Secret | `MCP_OAUTH_CLIENT_SECRET` you set during deploy |

Save. Anthropic walks the OAuth dance silently; on success the connector page shows the tools (49 if `CAPSULE_MCP_READONLY=1`, 87 if not).
Save. Anthropic walks the OAuth dance silently; on success the connector page shows the tools (49 if `CAPSULE_MCP_READONLY=1`, 88 if not).

## Wire up a shared Project

Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ where the bytes flow; the semantics are identical.
┌────────────────────────┐
│ src/tools/*.ts │
│ (87 tools across the │
│ (88 tools across the │
│ Capsule resource │
│ graph — see README) │
└────────────────────────┘
Expand Down
4 changes: 2 additions & 2 deletions HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ npm install
npm test
```

529 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:
536 tests, all mocked — no Capsule API calls happen, no token needed. The suite has three layers:

- **Per-tool unit tests** (e.g. `tests/parties.test.ts`): import the tool function, mock `undici.fetch`, assert on the URL, method, body, and response handling. Most tests live here.
- **MCP-protocol integration tests** (`tests/mcp-integration.test.ts`): drive a real `McpServer` through the wire protocol via the SDK's in-memory transport pair, with `undici.fetch` still mocked. Catches the layer between "tool function works" and "MCP correctly registers and dispatches the tool". Includes the `get_attachment` content-type routing logic (which lives in `server.ts`, not the tool function).
Expand Down Expand Up @@ -46,7 +46,7 @@ for the contributor-facing summary.
npm run build
```

Produces `dist/index.js` (stdio entry, ~165 KB, with `#!/usr/bin/env node` shebang and the executable bit set) and `dist/http.js` (HTTP entry, ~193 KB, no shebang). Each is fully self-contained — tsup runs as two separate configs so the stdio entry can be invoked directly via npx while the HTTP entry isn't a CLI. tsup target is Node 22 (undici 8 requires Node 22+ for the `webidl.util.markAsUncloneable` runtime API).
Produces `dist/index.js` (stdio entry, ~167 KB, with `#!/usr/bin/env node` shebang and the executable bit set) and `dist/http.js` (HTTP entry, ~195 KB, no shebang). Each is fully self-contained — tsup runs as two separate configs so the stdio entry can be invoked directly via npx while the HTTP entry isn't a CLI. tsup target is Node 22 (undici 8 requires Node 22+ for the `webidl.util.markAsUncloneable` runtime API).

`npm run build` also chains `npm run build:icon` (`scripts/build-icon.mjs`), which regenerates `src/icon.ts` from the canonical `assets/icon.svg`. The TypeScript file is committed (so typecheck works without a build step) but is **generated** — edit the SVG, then run the build. A drift-guard test (`tests/icon-source.test.ts`) fails CI if the two ever fall out of sync.

Expand Down
51 changes: 51 additions & 0 deletions NOTES-ON-CAPSULE-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,57 @@ default-on in callers without conditional logic.

---

## 33. No tenant-wide track-instance list; tag definitions ARE deletable

Two endpoint-existence questions, settled empirically by
[`scripts/wire-trace-v167.ts`](scripts/wire-trace-v167.ts) so that two
proposed tools could be graded buildable-or-not before any code was
written.

### `GET /tracks` → 405 — there is NO tenant-wide track-instance list

`GET /tracks` returns **`405 Method not allowed`** (the route exists
but only accepts `POST`, which creates an instance). Track instances
are therefore reachable only:

- entity-scoped: `GET /<entity>/{id}/tracks` (the `list_entity_tracks`
tool), or
- by known id: `GET /tracks/{id}` (the `show_track` tool).

**Consequence:** the orphan track instances from §25 (which survive
parent deletion) **cannot be enumerated tenant-wide** — if you don't
already hold the orphan's id, there is no read path to it. A proposed
`list_tracks` tool is therefore **not buildable** against Capsule's
v2 API; it was declined on this basis rather than the assumption it
could be added. The only mitigations for orphan accumulation remain
(a) capture the track-instance id at `apply_track` time and
`remove_track` it explicitly before deleting the parent, or (b) clean
up in Capsule's web UI.

### `DELETE /<entity>/tags/{id}` → 204 — tag DEFINITIONS are deletable

A tag definition minted via `add_tag` was removed with
**`DELETE /parties/tags/{id}` → 204`**, and a follow-up
`GET /parties/tags` confirmed it was gone tenant-wide (not merely
detached from one record). Tags are entity-namespaced (separate
`/parties/tags`, `/opportunities/tags`, `/kases/tags` lists), so the
delete path carries the entity prefix.

This is distinct from the tag DETACH path used by `remove_tag_by_id`
(`PUT /<entity>/{id}` with `{tags: [{id, _delete: true}]}`), which
removes a tag from ONE record and leaves the definition intact.
Definition-delete removes the definition from the namespace and from
every record that shared it.

**Where in our code:** [`src/tools/tags.ts`](src/tools/tags.ts)
`deleteTagDefinition` (the `delete_tag_definition` tool, confirm-gated,
idempotent on 404). Verified by `scripts/wire-trace-v167.ts`.

**No Capsule docs page** documents either behaviour explicitly;
both come from the live probe.

---

## How to add to this file

When you discover a new Capsule API quirk:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

A [Model Context Protocol](https://modelcontextprotocol.io) server for [Capsule CRM](https://capsulecrm.com). Connect Claude (Desktop, Code, or web Projects via Custom Connector) to your CRM and let it answer natural-language questions across the full record graph: contacts, organisations, opportunities, projects, tasks, and timeline activity. Beyond the basics it covers structured filters with field/operator conditions, saved searches with sort, workflow tracks (templates and instances), file attachments (read + write), audit of deleted records, and batch fetches up to 50 records per call.

- **87 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 6 batched-write tools (`batch_*`) for mass-update workflows
- **88 tools** across the Capsule resource graph (49 in read-only mode) — full read coverage plus careful, confirm-gated writes; 6 batched-write tools (`batch_*`) for mass-update workflows
- **Two transports**: stdio for local installs (Claude Desktop / Code), HTTP+OAuth for hosted Custom Connectors
- **Read-only mode** as a one-env-var flag; works alongside read-scoped Capsule tokens
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`, 7 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
- **MCP tool annotations**: 49 read tools carry `readOnlyHint: true`, 8 destructive ones carry `destructiveHint: true` — clients that honor these hints can auto-approve safe reads while still prompting for writes/destructive calls
- **Apache 2.0**

## Pick your install
Expand Down
216 changes: 216 additions & 0 deletions scripts/wire-trace-v167.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* Wire-trace probes for the connector-gap audit (post-v1.6.6). Answers
* the two questions that gate whether the proposed `list_tracks` and
* `delete_tag_definition` tools are even BUILDABLE — i.e. whether
* Capsule's v2 API exposes the upstream endpoints they'd need. The
* connector's own notes only document entity-scoped + by-id track
* reads and tag DETACH (never definition-delete), so both are
* unverified assumptions until probed.
*
* PROBE 1 — tenant-wide track-instance enumeration.
* Does `GET /tracks` list all track instances across the tenant?
* If yes (200 + a `tracks` array), a `list_tracks` tool is
* buildable and orphan track instances (which survive parent
* deletion — NOTES §25) become sweepable. If 404/405, there is
* no read-side path to orphans by-anything-but-known-id, and
* `list_tracks` is NOT buildable.
*
* PROBE 2 — tag-definition deletion.
* `add_tag` auto-creates a tenant-global tag definition; the
* connector can DETACH a tag from an entity (`remove_tag_by_id`)
* but never DELETE the definition. Does Capsule expose a
* definition-delete endpoint? Probes `DELETE /parties/tags/{id}`
* then `DELETE /tags/{id}`. If either 200/204s, a
* `delete_tag_definition` tool is buildable.
*
* CAVEAT: this probe must create a throwaway tag definition to
* attempt deleting it. If NO delete endpoint works, that
* definition is stranded in the tenant (there's no API path to
* remove it — which is precisely the gap being measured). It is
* labelled `ZZZ-V167-DELME-*` so it's trivially findable for
* manual web-UI cleanup. The probe reports whether it stranded.
*
* Pattern mirrors scripts/wire-trace-v164.ts … -v166.ts: ZZZ-V167-*
* labelled test records, full cleanup on exit, no tenant-specific
* strings or IDs committed (everything discovered at runtime). Run
* with:
*
* CAPSULE_API_TOKEN=<write-scoped> npx tsx scripts/wire-trace-v167.ts
*/

import { fetch } from "undici";

const TOKEN = process.env["CAPSULE_API_TOKEN"];
if (!TOKEN) {
console.error("CAPSULE_API_TOKEN env var required (write-scoped)");
process.exit(1);
}

const BASE = "https://api.capsulecrm.com/api/v2";
const HEADERS = {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json",
};

interface ApiResult {
status: number;
body: unknown;
}

async function call(method: string, path: string, body?: unknown): Promise<ApiResult> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: HEADERS,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = await res.text().catch(() => null);
}
return { status: res.status, body: parsed };
}

/** Compact one-line view of a response body, capped. */
function bodyPreview(body: unknown): string {
return JSON.stringify(body)?.slice(0, 300) ?? "null";
}

/** Pull the array under a top-level key (e.g. `tracks`, `tags`), or []. */
function arrayUnder(body: unknown, key: string): Array<Record<string, unknown>> {
if (body && typeof body === "object" && key in (body as Record<string, unknown>)) {
const arr = (body as Record<string, unknown>)[key];
if (Array.isArray(arr)) return arr as Array<Record<string, unknown>>;
}
return [];
}

async function probeTenantWideTracks(): Promise<void> {
console.log("\n=========================================");
console.log("PROBE 1: GET /tracks — is there a tenant-wide track-instance list?");
console.log("=========================================");

const res = await call("GET", "/tracks?page=1&perPage=2");
console.log(` GET /tracks → status ${res.status}`);
if (res.status === 200) {
const tracks = arrayUnder(res.body, "tracks");
const keys = Object.keys((res.body as Record<string, unknown>) ?? {});
console.log(` top-level keys: ${JSON.stringify(keys)}`);
console.log(` tracks[] present? ${tracks.length > 0 || keys.includes("tracks")}`);
console.log(` → BUILDABLE: a list_tracks tool can enumerate tenant-wide instances.`);
console.log(` sample: ${bodyPreview(res.body)}`);
} else if (res.status === 404 || res.status === 405) {
console.log(` → NOT BUILDABLE: no tenant-wide track-instance endpoint.`);
console.log(` Orphan tracks are reachable only by known id (GET /tracks/{id})`);
console.log(` or entity-scoped (GET /<entity>/{id}/tracks). list_tracks is moot.`);
console.log(` body: ${bodyPreview(res.body)}`);
} else {
console.log(` → INCONCLUSIVE (unexpected status). body: ${bodyPreview(res.body)}`);
}

// Contrast: confirm the entity-scoped + definitions endpoints behave
// as the connector already assumes (sanity that the token can read
// tracks at all, so a 404 above is "no endpoint" not "no access").
const defs = await call("GET", "/tracks/definitions?perPage=1");
console.log(
` contrast GET /tracks/definitions → status ${defs.status} (definitions list the connector already uses)`,
);
}

async function probeTagDefinitionDelete(): Promise<void> {
console.log("\n=========================================");
console.log("PROBE 2: tag-definition deletion — DELETE /parties/tags/{id} then /tags/{id}");
console.log("=========================================");

const tag = `ZZZ-V167-${Date.now()}`;
const tagName = `ZZZ-V167-DELME-${Date.now()}`;
let hostPartyId: number | undefined;
let tagId: number | undefined;
let stranded = true;

try {
// Host party to attach the throwaway tag to (add_tag's path is a
// PUT on an entity; you can't mint a definition without one).
const host = await call("POST", "/parties", {
party: { type: "organisation", name: `${tag}-TAGHOST` },
});
if (host.status !== 201) {
console.log(` setup failed (create host party → ${host.status}); aborting probe 2`);
return;
}
hostPartyId = (host.body as { party: { id: number } }).party.id;
console.log(` host party ${hostPartyId}`);

// Attach a fresh-named tag → Capsule auto-creates the definition.
// The PUT response does NOT echo the tags array, so read the party
// back with embed=tags to get the tag object (which carries the
// tenant-global definition id — NOTES §20).
await call("PUT", `/parties/${hostPartyId}`, {
party: { tags: [{ name: tagName }] },
});
const read = await call("GET", `/parties/${hostPartyId}?embed=tags`);
const tags = arrayUnder((read.body as { party?: unknown })?.party ?? {}, "tags");
const created = tags.find((t) => t["name"] === tagName);
tagId = typeof created?.["id"] === "number" ? (created["id"] as number) : undefined;
console.log(` created tag definition "${tagName}" → id ${tagId ?? "UNKNOWN"}`);
if (tagId === undefined) {
console.log(` could not resolve new tag id; party+tags body: ${bodyPreview(read.body)}`);
return;
}

// Candidate delete endpoints, in order. Stop at first 2xx.
const candidates = [`/parties/tags/${tagId}`, `/tags/${tagId}`];
for (const path of candidates) {
const del = await call("DELETE", path);
console.log(` DELETE ${path} → status ${del.status}`);
if (del.status === 200 || del.status === 204) {
console.log(` → BUILDABLE: delete_tag_definition can use DELETE ${path}`);
stranded = false;
break;
}
if (del.status !== 404 && del.status !== 405) {
console.log(` (non-404/405 body: ${bodyPreview(del.body)})`);
}
}
if (stranded) {
console.log(` → NOT BUILDABLE: no tag-definition delete endpoint responded 2xx.`);
console.log(` Tag definitions can only be removed via Capsule's web UI.`);
}
} finally {
if (hostPartyId !== undefined) {
const d = await call("DELETE", `/parties/${hostPartyId}`);
console.log(` cleanup: delete host party ${hostPartyId} → ${d.status}`);
}
// Verify whether the definition outlived the host party (it will,
// if no delete endpoint worked — definitions are tenant-global,
// independent of any entity).
if (tagId !== undefined) {
const check = await call("GET", "/parties/tags?perPage=100");
const present = arrayUnder(check.body, "tags").some((t) => t["id"] === tagId);
console.log(
` post-cleanup: tag definition ${tagId} still in tenant? ${present ? "YES" : "no"}`,
);
if (present) {
console.log(` ⚠ STRANDED: search Capsule's web UI for "${tagName}" and delete manually.`);
}
}
}
}

async function main() {
try {
await probeTenantWideTracks();
await probeTagDefinitionDelete();
console.log("\nAll probes complete.");
} catch (err) {
console.error("\n!!! probe run crashed:", err);
process.exit(1);
}
}

main().catch((err) => {
console.error("fatal:", err);
process.exit(1);
});
10 changes: 10 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ import {
addTag,
removeTagByIdSchema,
removeTagById,
deleteTagDefinitionSchema,
deleteTagDefinition,
batchAddTagSchema,
batchAddTag,
batchRemoveTagByIdSchema,
Expand Down Expand Up @@ -1097,6 +1099,14 @@ export function createCapsuleMcpServer(opts?: { clientId?: string }): McpServer
removeTagById,
);

registerTool(
server,
"delete_tag_definition",
"DESTRUCTIVE & TENANT-WIDE: permanently delete a tag DEFINITION from an entity type's tag namespace (parties / opportunities / kases). Unlike remove_tag_by_id — which detaches a tag from ONE record and leaves the definition intact for others — this removes the definition itself, so the tag disappears from EVERY record that shared it. Use it to clean up stray / mistyped / test tag definitions polluting the tenant-global list. Requires confirm=true. Always read the affected tag first via list_tags and confirm with the user; if you only want to untag one record, use remove_tag_by_id instead. Irreversible (re-creating by name via add_tag mints a brand-new id). Idempotent on retry: `{deleted: true, alreadyDeleted: false, entity, tagId}` on a fresh delete, or `{deleted: true, alreadyDeleted: true, entity, tagId}` if the definition was already gone (Capsule's 404 is caught). Endpoint verified empirically (DELETE /<entity>/tags/{id} → 204).",
deleteTagDefinitionSchema,
deleteTagDefinition,
);

registerBatchTool(
server,
"batch_add_tag",
Expand Down
Loading