From 20a890f3c77f1e2328d6874cf79828653c57103b Mon Sep 17 00:00:00 2001 From: Ashwin Date: Tue, 2 Jun 2026 06:49:16 +0530 Subject: [PATCH] feat: add MCP client configuration picker and copy-to-clipboard functionality to API tokens settings --- README.md | 13 ++ .../(app)/settings/api-tokens/+page.server.js | 11 +- .../(app)/settings/api-tokens/+page.svelte | 127 ++++++++++++++++-- mcp_server/README.md | 54 ++++++-- 4 files changed, 182 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7aeecace9..ac323bb00 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ BottleCRM is a full-featured Customer Relationship Management system designed fo - **Tags** - Flexible tagging system for organizing records - **Email Integration** - AWS SES integration for transactional emails - **Background Tasks** - Celery + Redis for async task processing +- **AI Agents (MCP)** - Built-in [Model Context Protocol](https://modelcontextprotocol.io) server (`mcp_server/`) lets Claude, Cursor, Codex, Gemini and any MCP client search, create and update records via a personal access token — acting as you, with your role and permissions. See [`mcp_server/README.md`](mcp_server/README.md). ## Tech Stack @@ -139,6 +140,16 @@ uv run celery -A crm worker --loglevel=INFO - **API Documentation**: http://localhost:8000/swagger-ui/ - **Admin Panel**: http://localhost:8000/admin/ +### Connect your AI agent (MCP) + +Let Claude, Cursor, Codex, Gemini, or any MCP client work in your CRM: + +1. In the app, go to **Settings → API Tokens** and create a personal access token (shown once). +2. Register the `bcrm-mcp` server in your AI client, passing `BCRM_BASE_URL` (your API host, e.g. `http://localhost:8000`) and `BCRM_TOKEN` (the token). The token page shows ready-to-paste config for each client. +3. Restart the client and start asking. + +The agent authenticates **as you** and inherits your role, org and RLS scope — it can't see or do anything you can't. Full setup, the tool list, and the security model are in [`mcp_server/README.md`](mcp_server/README.md). + ## Docker Setup Run the full stack (backend, frontend, PostgreSQL, Redis, Celery) with a single command: @@ -204,6 +215,8 @@ Django-CRM/ │ │ └── (no-layout)/ # Auth pages (login, etc.) │ ├── static/ # Static assets │ └── Dockerfile # Frontend dev container +├── mcp_server/ # MCP server (bcrm-mcp) for AI agents +│ └── src/bcrm_mcp/ # FastMCP tools over the REST API (stdio transport) ├── docker/ # Docker support files │ ├── backend/ │ │ └── entrypoint.sh # DB wait + migrate + runserver diff --git a/frontend/src/routes/(app)/settings/api-tokens/+page.server.js b/frontend/src/routes/(app)/settings/api-tokens/+page.server.js index e6e6c6753..67049a7dd 100644 --- a/frontend/src/routes/(app)/settings/api-tokens/+page.server.js +++ b/frontend/src/routes/(app)/settings/api-tokens/+page.server.js @@ -1,14 +1,21 @@ import { fail } from '@sveltejs/kit'; +import { env } from '$env/dynamic/public'; import { apiRequest } from '$lib/api-helpers.js'; +// The MCP server runs on the user's own machine, so BCRM_BASE_URL must be the +// PUBLIC API host (e.g. https://api.bottlecrm.io) — the same base the browser +// talks to. PUBLIC_DJANGO_API_URL is that host with no /api suffix, which is +// exactly what the MCP client expects (it appends /api/... itself). +const apiBaseUrl = env.PUBLIC_DJANGO_API_URL || 'https://api.bottlecrm.io'; + /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies, locals }) { try { const data = await apiRequest('/profile/tokens/', {}, { cookies, org: locals?.org }); - return { tokens: data.tokens || [] }; + return { tokens: data.tokens || [], baseUrl: apiBaseUrl }; } catch (err) { console.error('Failed to load API tokens:', err); - return { tokens: [], loadError: err?.message || 'Failed to load tokens' }; + return { tokens: [], baseUrl: apiBaseUrl, loadError: err?.message || 'Failed to load tokens' }; } } diff --git a/frontend/src/routes/(app)/settings/api-tokens/+page.svelte b/frontend/src/routes/(app)/settings/api-tokens/+page.svelte index 20e073aee..cfdf7f948 100644 --- a/frontend/src/routes/(app)/settings/api-tokens/+page.svelte +++ b/frontend/src/routes/(app)/settings/api-tokens/+page.svelte @@ -23,6 +23,9 @@ let { data, form } = $props(); const tokens = $derived(data.tokens || []); + // Real, ready-to-paste API host (e.g. https://api.bottlecrm.io). The MCP + // client appends /api/... itself, so this is exactly BCRM_BASE_URL. + const baseUrl = $derived(data.baseUrl || 'https://api.bottlecrm.io'); let formName = $state(''); let formExpiresAt = $state(''); @@ -33,19 +36,60 @@ /** @type {string} */ let confirmingId = $state(''); let helpOpen = $state(false); + let configCopied = $state(false); + let selectedClient = $state('claude'); - const claudeConfig = `{ + // The just-created raw token if we have it, else a paste-your-token hint. We + // can only ever show the real token in the same response that created it. + const tokenValue = $derived(form?.created?.token || 'bcrm_pat_…paste-your-token'); + + /** + * MCP clients we give a ready-to-paste config for. Claude Desktop, Cursor and + * Gemini CLI share the identical `mcpServers` JSON schema (only the config + * file differs); Codex CLI uses TOML. + */ + const CLIENTS = [ + { id: 'claude', label: 'Claude Desktop', lang: 'json', file: 'claude_desktop_config.json — Settings → Developer → Edit Config' }, + { id: 'cursor', label: 'Cursor', lang: 'json', file: '~/.cursor/mcp.json (global) or .cursor/mcp.json (per project)' }, + { id: 'codex', label: 'Codex CLI', lang: 'toml', file: '~/.codex/config.toml' }, + { id: 'gemini', label: 'Gemini CLI', lang: 'json', file: '~/.gemini/settings.json' } + ]; + + /** @param {string} base @param {string} token */ + function jsonConfig(base, token) { + return `{ "mcpServers": { "bottlecrm": { "command": "uvx", "args": ["bcrm-mcp"], "env": { - "BCRM_BASE_URL": "", - "BCRM_TOKEN": "bcrm_pat_… (paste the token you just created)" + "BCRM_BASE_URL": "${base}", + "BCRM_TOKEN": "${token}" } } } }`; + } + + /** @param {string} base @param {string} token */ + function tomlConfig(base, token) { + return `[mcp_servers.bottlecrm] +command = "uvx" +args = ["bcrm-mcp"] + +[mcp_servers.bottlecrm.env] +BCRM_BASE_URL = "${base}" +BCRM_TOKEN = "${token}"`; + } + + const selectedClientMeta = $derived( + CLIENTS.find((c) => c.id === selectedClient) || CLIENTS[0] + ); + const configSnippet = $derived( + selectedClientMeta.lang === 'toml' + ? tomlConfig(baseUrl, tokenValue) + : jsonConfig(baseUrl, tokenValue) + ); $effect(() => { if (form?.created?.token) { @@ -53,6 +97,9 @@ formName = ''; formExpiresAt = ''; copied = false; + // Surface the connect instructions immediately — the config now carries + // the real token, which the user can only copy from this one response. + helpOpen = true; } else if (form?.revoked) { toast.success('Token revoked'); confirmingId = ''; @@ -74,6 +121,18 @@ } } + /** @param {string} value */ + async function copyConfig(value) { + try { + await navigator.clipboard.writeText(value); + configCopied = true; + toast.success('Config copied to clipboard'); + setTimeout(() => (configCopied = false), 2000); + } catch { + toast.error('Could not copy — select and copy manually'); + } + } + /** @param {string | null | undefined} value */ function formatDate(value) { if (!value) return 'Never'; @@ -305,19 +364,65 @@ {/if} - - Connect your AI (Claude Desktop) - + Connect your AI {#if helpOpen}
+ +
+ {#each CLIENTS as client (client.id)} + + {/each} +
+

- Add the following to your Claude Desktop MCP config, replacing - BCRM_BASE_URL with your CRM URL and - BCRM_TOKEN with the token you just created. + Add this to + {selectedClientMeta.file}, then restart {selectedClientMeta.label}. + {#if form?.created?.token} + The token below is yours — it's shown only this once. + {:else} + Replace BCRM_TOKEN with a token you created above. + {/if} +

+ +
+ +
{configSnippet}
+
+ +

+ Requires uv + (provides uvx) on your machine. The agent acts as you and + inherits your role — it can't see or do anything you can't.

-
{claudeConfig}
{/if} diff --git a/mcp_server/README.md b/mcp_server/README.md index 8fb64ce52..39724b5a1 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -53,7 +53,8 @@ same CRM access as that user, scoped to their org — treat it like a password. In the CRM go to **Settings → API Tokens**, create a token, and copy the `bcrm_pat_…` value. **The raw token is shown only once at creation.** You can -revoke it from the same screen at any time. *(This UI ships with Phase D.)* +revoke it from the same screen at any time. That page also shows ready-to-paste +config for each client below, pre-filled with your API host and token. ### Option B — Django shell (local / dev) @@ -68,11 +69,22 @@ The PAT table is created by the `common` app migrations — if you get a `relation "personal_access_token" does not exist` error, run `uv run python manage.py migrate common` first. -## Claude Desktop configuration +## Client configuration -Add an entry to your Claude Desktop MCP config (`claude_desktop_config.json`). +Register `bcrm-mcp` in your AI client and pass `BCRM_BASE_URL` + `BCRM_TOKEN` as +environment variables. **Claude Desktop, Cursor, and Gemini CLI share the +identical `mcpServers` JSON schema** — only the config file differs. **Codex CLI +uses TOML.** Replace `http://localhost:8000` with your API host (e.g. +`https://api.bottlecrm.io`) and paste your `bcrm_pat_…` token. -### Once the package is published +| Client | Config file | Format | +| -------------- | ---------------------------------------------------------------------- | ------ | +| Claude Desktop | `claude_desktop_config.json` (Settings → Developer → Edit Config) | JSON | +| Cursor | `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project) | JSON | +| Gemini CLI | `~/.gemini/settings.json` | JSON | +| Codex CLI | `~/.codex/config.toml` | TOML | + +### JSON clients (Claude Desktop / Cursor / Gemini CLI) ```json { @@ -89,11 +101,26 @@ Add an entry to your Claude Desktop MCP config (`claude_desktop_config.json`). } ``` +### Codex CLI (TOML) + +```toml +[mcp_servers.bottlecrm] +command = "uvx" +args = ["bcrm-mcp"] + +[mcp_servers.bottlecrm.env] +BCRM_BASE_URL = "http://localhost:8000" +BCRM_TOKEN = "bcrm_pat_…" +``` + +Restart the client after editing its config; it launches `bcrm-mcp` for you and +discovers the tools automatically. + ### Until published — run from a local checkout -`bcrm-mcp` is not yet on PyPI, so point `uv run` at this directory instead. Use -`--directory` with an absolute path so it works regardless of the client's -working directory: +`bcrm-mcp` is not yet on PyPI, so point the command at this directory instead of +using `uvx`. Use `--directory` with an absolute path so it works regardless of +the client's working directory. For the JSON clients: ```json { @@ -110,6 +137,8 @@ working directory: } ``` +For Codex, set `command = "uv"` and +`args = ["run", "--directory", "/abs/path/to/mcp_server", "bcrm-mcp"]`. (Equivalently, from inside `mcp_server` you can just run `uv run bcrm-mcp`.) ## Available tools @@ -125,7 +154,7 @@ invoices, solutions**. | `crm_create` | write | Create a record from a `data` object (validated server-side by the API). | | `crm_update` | write | Partially update a record (PATCH semantics) from a `data` object. | | `crm_delete` | destructive | Delete a record. **Requires `confirm=true`** — refuses to run otherwise. | -| `crm_action` | write | Run a non-CRUD action on a record (see `list_actions`), e.g. `convert`, `add_comment`, `send`. | +| `crm_action` | write | Run a non-CRUD action on a record (see `list_actions`), e.g. `convert`, `add_comment`, `send`. Outward-facing actions (`send`) **require `confirm=true`**. | | `crm_describe` | read-only | Return an entity's fields, types, enums, and which are required — derived from the live OpenAPI schema. | | `list_actions` | read-only | Return the allowed non-CRUD actions for each entity. | @@ -144,6 +173,9 @@ invoices, solutions**. > Note: solutions are served under the **cases** app at `/api/cases/solutions/`, > not at a top-level `/api/solutions/`. +> +> `send` is outward-facing (emails a customer), so `crm_action` requires +> `confirm=true` for it — see the security model below. ## Security model @@ -156,8 +188,10 @@ invoices, solutions**. it does not re-implement (or relax) any of those checks. - **Read limits.** `crm_search` caps `limit` at 50 regardless of what the agent requests, to avoid pulling unbounded result sets. -- **Destructive ops are gated.** `crm_delete` refuses to run without - `confirm=true`, so a model can't delete a record by accident. +- **Destructive & outward-facing ops are gated.** `crm_delete` refuses to run + without `confirm=true`, and so do outward-facing actions like + `crm_action(..., action="send")` (which emails a customer) — so a model can't + delete a record or send an invoice by accident. - **Tokens are revocable.** Revoke a PAT from the CRM (Settings → API Tokens) to immediately cut off an agent. Tokens may also carry an expiry. - **Never commit a token.** Keep `BCRM_TOKEN` out of source control, logs, and