Skip to content

Commit 3ab79d7

Browse files
authored
Merge pull request #695 from MicroPyramid/dev
feat: add MCP client configuration picker and copy-to-clipboard funct…
2 parents e320dfc + 20a890f commit 3ab79d7

4 files changed

Lines changed: 182 additions & 23 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ BottleCRM is a full-featured Customer Relationship Management system designed fo
3737
- **Tags** - Flexible tagging system for organizing records
3838
- **Email Integration** - AWS SES integration for transactional emails
3939
- **Background Tasks** - Celery + Redis for async task processing
40+
- **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).
4041

4142
## Tech Stack
4243

@@ -139,6 +140,16 @@ uv run celery -A crm worker --loglevel=INFO
139140
- **API Documentation**: http://localhost:8000/swagger-ui/
140141
- **Admin Panel**: http://localhost:8000/admin/
141142

143+
### Connect your AI agent (MCP)
144+
145+
Let Claude, Cursor, Codex, Gemini, or any MCP client work in your CRM:
146+
147+
1. In the app, go to **Settings → API Tokens** and create a personal access token (shown once).
148+
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.
149+
3. Restart the client and start asking.
150+
151+
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).
152+
142153
## Docker Setup
143154

144155
Run the full stack (backend, frontend, PostgreSQL, Redis, Celery) with a single command:
@@ -204,6 +215,8 @@ Django-CRM/
204215
│ │ └── (no-layout)/ # Auth pages (login, etc.)
205216
│ ├── static/ # Static assets
206217
│ └── Dockerfile # Frontend dev container
218+
├── mcp_server/ # MCP server (bcrm-mcp) for AI agents
219+
│ └── src/bcrm_mcp/ # FastMCP tools over the REST API (stdio transport)
207220
├── docker/ # Docker support files
208221
│ ├── backend/
209222
│ │ └── entrypoint.sh # DB wait + migrate + runserver

frontend/src/routes/(app)/settings/api-tokens/+page.server.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { fail } from '@sveltejs/kit';
2+
import { env } from '$env/dynamic/public';
23
import { apiRequest } from '$lib/api-helpers.js';
34

5+
// The MCP server runs on the user's own machine, so BCRM_BASE_URL must be the
6+
// PUBLIC API host (e.g. https://api.bottlecrm.io) — the same base the browser
7+
// talks to. PUBLIC_DJANGO_API_URL is that host with no /api suffix, which is
8+
// exactly what the MCP client expects (it appends /api/... itself).
9+
const apiBaseUrl = env.PUBLIC_DJANGO_API_URL || 'https://api.bottlecrm.io';
10+
411
/** @type {import('./$types').PageServerLoad} */
512
export async function load({ cookies, locals }) {
613
try {
714
const data = await apiRequest('/profile/tokens/', {}, { cookies, org: locals?.org });
8-
return { tokens: data.tokens || [] };
15+
return { tokens: data.tokens || [], baseUrl: apiBaseUrl };
916
} catch (err) {
1017
console.error('Failed to load API tokens:', err);
11-
return { tokens: [], loadError: err?.message || 'Failed to load tokens' };
18+
return { tokens: [], baseUrl: apiBaseUrl, loadError: err?.message || 'Failed to load tokens' };
1219
}
1320
}
1421

frontend/src/routes/(app)/settings/api-tokens/+page.svelte

Lines changed: 116 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
let { data, form } = $props();
2424
2525
const tokens = $derived(data.tokens || []);
26+
// Real, ready-to-paste API host (e.g. https://api.bottlecrm.io). The MCP
27+
// client appends /api/... itself, so this is exactly BCRM_BASE_URL.
28+
const baseUrl = $derived(data.baseUrl || 'https://api.bottlecrm.io');
2629
2730
let formName = $state('');
2831
let formExpiresAt = $state('');
@@ -33,26 +36,70 @@
3336
/** @type {string} */
3437
let confirmingId = $state('');
3538
let helpOpen = $state(false);
39+
let configCopied = $state(false);
40+
let selectedClient = $state('claude');
3641
37-
const claudeConfig = `{
42+
// The just-created raw token if we have it, else a paste-your-token hint. We
43+
// can only ever show the real token in the same response that created it.
44+
const tokenValue = $derived(form?.created?.token || 'bcrm_pat_…paste-your-token');
45+
46+
/**
47+
* MCP clients we give a ready-to-paste config for. Claude Desktop, Cursor and
48+
* Gemini CLI share the identical `mcpServers` JSON schema (only the config
49+
* file differs); Codex CLI uses TOML.
50+
*/
51+
const CLIENTS = [
52+
{ id: 'claude', label: 'Claude Desktop', lang: 'json', file: 'claude_desktop_config.json — Settings → Developer → Edit Config' },
53+
{ id: 'cursor', label: 'Cursor', lang: 'json', file: '~/.cursor/mcp.json (global) or .cursor/mcp.json (per project)' },
54+
{ id: 'codex', label: 'Codex CLI', lang: 'toml', file: '~/.codex/config.toml' },
55+
{ id: 'gemini', label: 'Gemini CLI', lang: 'json', file: '~/.gemini/settings.json' }
56+
];
57+
58+
/** @param {string} base @param {string} token */
59+
function jsonConfig(base, token) {
60+
return `{
3861
"mcpServers": {
3962
"bottlecrm": {
4063
"command": "uvx",
4164
"args": ["bcrm-mcp"],
4265
"env": {
43-
"BCRM_BASE_URL": "<your CRM URL>",
44-
"BCRM_TOKEN": "bcrm_pat_… (paste the token you just created)"
66+
"BCRM_BASE_URL": "${base}",
67+
"BCRM_TOKEN": "${token}"
4568
}
4669
}
4770
}
4871
}`;
72+
}
73+
74+
/** @param {string} base @param {string} token */
75+
function tomlConfig(base, token) {
76+
return `[mcp_servers.bottlecrm]
77+
command = "uvx"
78+
args = ["bcrm-mcp"]
79+
80+
[mcp_servers.bottlecrm.env]
81+
BCRM_BASE_URL = "${base}"
82+
BCRM_TOKEN = "${token}"`;
83+
}
84+
85+
const selectedClientMeta = $derived(
86+
CLIENTS.find((c) => c.id === selectedClient) || CLIENTS[0]
87+
);
88+
const configSnippet = $derived(
89+
selectedClientMeta.lang === 'toml'
90+
? tomlConfig(baseUrl, tokenValue)
91+
: jsonConfig(baseUrl, tokenValue)
92+
);
4993
5094
$effect(() => {
5195
if (form?.created?.token) {
5296
// Reset the create form after a successful creation.
5397
formName = '';
5498
formExpiresAt = '';
5599
copied = false;
100+
// Surface the connect instructions immediately — the config now carries
101+
// the real token, which the user can only copy from this one response.
102+
helpOpen = true;
56103
} else if (form?.revoked) {
57104
toast.success('Token revoked');
58105
confirmingId = '';
@@ -74,6 +121,18 @@
74121
}
75122
}
76123
124+
/** @param {string} value */
125+
async function copyConfig(value) {
126+
try {
127+
await navigator.clipboard.writeText(value);
128+
configCopied = true;
129+
toast.success('Config copied to clipboard');
130+
setTimeout(() => (configCopied = false), 2000);
131+
} catch {
132+
toast.error('Could not copy — select and copy manually');
133+
}
134+
}
135+
77136
/** @param {string | null | undefined} value */
78137
function formatDate(value) {
79138
if (!value) return 'Never';
@@ -305,19 +364,65 @@
305364
<ChevronRight class="h-4 w-4 text-[var(--text-secondary)]" />
306365
{/if}
307366
<KeyRound class="h-4 w-4 text-[var(--text-secondary)]" />
308-
<span class="text-base font-medium text-[var(--text-primary)]">
309-
Connect your AI (Claude Desktop)
310-
</span>
367+
<span class="text-base font-medium text-[var(--text-primary)]"> Connect your AI </span>
311368
</button>
312369
{#if helpOpen}
313370
<div class="space-y-3 border-t border-[var(--border-default)] p-4">
371+
<!-- Client picker -->
372+
<div class="flex flex-wrap gap-2">
373+
{#each CLIENTS as client (client.id)}
374+
<Button
375+
type="button"
376+
size="sm"
377+
variant={selectedClient === client.id ? 'default' : 'outline'}
378+
onclick={() => (selectedClient = client.id)}
379+
>
380+
{client.label}
381+
</Button>
382+
{/each}
383+
</div>
384+
314385
<p class="text-sm text-[var(--text-secondary)]">
315-
Add the following to your Claude Desktop MCP config, replacing
316-
<code class="font-mono text-xs">BCRM_BASE_URL</code> with your CRM URL and
317-
<code class="font-mono text-xs">BCRM_TOKEN</code> with the token you just created.
386+
Add this to
387+
<code class="font-mono text-xs text-[var(--text-primary)]">{selectedClientMeta.file}</code
388+
>, then restart {selectedClientMeta.label}.
389+
{#if form?.created?.token}
390+
The token below is yours — it's shown only this once.
391+
{:else}
392+
Replace <code class="font-mono text-xs">BCRM_TOKEN</code> with a token you created above.
393+
{/if}
394+
</p>
395+
396+
<div class="relative">
397+
<Button
398+
type="button"
399+
size="sm"
400+
variant="outline"
401+
class="absolute right-2 top-2 gap-1"
402+
onclick={() => copyConfig(configSnippet)}
403+
>
404+
{#if configCopied}
405+
<Check class="h-3.5 w-3.5" />
406+
Copied
407+
{:else}
408+
<Copy class="h-3.5 w-3.5" />
409+
Copy
410+
{/if}
411+
</Button>
412+
<pre
413+
class="overflow-x-auto rounded-md border border-[var(--border-default)] bg-[var(--surface-muted)] p-3 pr-20 font-mono text-xs text-[var(--text-primary)]">{configSnippet}</pre>
414+
</div>
415+
416+
<p class="text-xs text-[var(--text-secondary)]">
417+
Requires <a
418+
href="https://docs.astral.sh/uv/"
419+
target="_blank"
420+
rel="noopener noreferrer"
421+
class="underline">uv</a
422+
>
423+
(provides <code class="font-mono">uvx</code>) on your machine. The agent acts as you and
424+
inherits your role — it can't see or do anything you can't.
318425
</p>
319-
<pre
320-
class="overflow-x-auto rounded-md border border-[var(--border-default)] bg-[var(--surface-muted)] p-3 font-mono text-xs text-[var(--text-primary)]">{claudeConfig}</pre>
321426
</div>
322427
{/if}
323428
</section>

mcp_server/README.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ same CRM access as that user, scoped to their org — treat it like a password.
5353

5454
In the CRM go to **Settings → API Tokens**, create a token, and copy the
5555
`bcrm_pat_…` value. **The raw token is shown only once at creation.** You can
56-
revoke it from the same screen at any time. *(This UI ships with Phase D.)*
56+
revoke it from the same screen at any time. That page also shows ready-to-paste
57+
config for each client below, pre-filled with your API host and token.
5758

5859
### Option B — Django shell (local / dev)
5960

@@ -68,11 +69,22 @@ The PAT table is created by the `common` app migrations — if you get a
6869
`relation "personal_access_token" does not exist` error, run
6970
`uv run python manage.py migrate common` first.
7071

71-
## Claude Desktop configuration
72+
## Client configuration
7273

73-
Add an entry to your Claude Desktop MCP config (`claude_desktop_config.json`).
74+
Register `bcrm-mcp` in your AI client and pass `BCRM_BASE_URL` + `BCRM_TOKEN` as
75+
environment variables. **Claude Desktop, Cursor, and Gemini CLI share the
76+
identical `mcpServers` JSON schema** — only the config file differs. **Codex CLI
77+
uses TOML.** Replace `http://localhost:8000` with your API host (e.g.
78+
`https://api.bottlecrm.io`) and paste your `bcrm_pat_…` token.
7479

75-
### Once the package is published
80+
| Client | Config file | Format |
81+
| -------------- | ---------------------------------------------------------------------- | ------ |
82+
| Claude Desktop | `claude_desktop_config.json` (Settings → Developer → Edit Config) | JSON |
83+
| Cursor | `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project) | JSON |
84+
| Gemini CLI | `~/.gemini/settings.json` | JSON |
85+
| Codex CLI | `~/.codex/config.toml` | TOML |
86+
87+
### JSON clients (Claude Desktop / Cursor / Gemini CLI)
7688

7789
```json
7890
{
@@ -89,11 +101,26 @@ Add an entry to your Claude Desktop MCP config (`claude_desktop_config.json`).
89101
}
90102
```
91103

104+
### Codex CLI (TOML)
105+
106+
```toml
107+
[mcp_servers.bottlecrm]
108+
command = "uvx"
109+
args = ["bcrm-mcp"]
110+
111+
[mcp_servers.bottlecrm.env]
112+
BCRM_BASE_URL = "http://localhost:8000"
113+
BCRM_TOKEN = "bcrm_pat_…"
114+
```
115+
116+
Restart the client after editing its config; it launches `bcrm-mcp` for you and
117+
discovers the tools automatically.
118+
92119
### Until published — run from a local checkout
93120

94-
`bcrm-mcp` is not yet on PyPI, so point `uv run` at this directory instead. Use
95-
`--directory` with an absolute path so it works regardless of the client's
96-
working directory:
121+
`bcrm-mcp` is not yet on PyPI, so point the command at this directory instead of
122+
using `uvx`. Use `--directory` with an absolute path so it works regardless of
123+
the client's working directory. For the JSON clients:
97124

98125
```json
99126
{
@@ -110,6 +137,8 @@ working directory:
110137
}
111138
```
112139

140+
For Codex, set `command = "uv"` and
141+
`args = ["run", "--directory", "/abs/path/to/mcp_server", "bcrm-mcp"]`.
113142
(Equivalently, from inside `mcp_server` you can just run `uv run bcrm-mcp`.)
114143

115144
## Available tools
@@ -125,7 +154,7 @@ invoices, solutions**.
125154
| `crm_create` | write | Create a record from a `data` object (validated server-side by the API). |
126155
| `crm_update` | write | Partially update a record (PATCH semantics) from a `data` object. |
127156
| `crm_delete` | destructive | Delete a record. **Requires `confirm=true`** — refuses to run otherwise. |
128-
| `crm_action` | write | Run a non-CRUD action on a record (see `list_actions`), e.g. `convert`, `add_comment`, `send`. |
157+
| `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`**. |
129158
| `crm_describe` | read-only | Return an entity's fields, types, enums, and which are required — derived from the live OpenAPI schema. |
130159
| `list_actions` | read-only | Return the allowed non-CRUD actions for each entity. |
131160

@@ -144,6 +173,9 @@ invoices, solutions**.
144173

145174
> Note: solutions are served under the **cases** app at `/api/cases/solutions/`,
146175
> not at a top-level `/api/solutions/`.
176+
>
177+
> `send` is outward-facing (emails a customer), so `crm_action` requires
178+
> `confirm=true` for it — see the security model below.
147179
148180
## Security model
149181

@@ -156,8 +188,10 @@ invoices, solutions**.
156188
it does not re-implement (or relax) any of those checks.
157189
- **Read limits.** `crm_search` caps `limit` at 50 regardless of what the agent
158190
requests, to avoid pulling unbounded result sets.
159-
- **Destructive ops are gated.** `crm_delete` refuses to run without
160-
`confirm=true`, so a model can't delete a record by accident.
191+
- **Destructive & outward-facing ops are gated.** `crm_delete` refuses to run
192+
without `confirm=true`, and so do outward-facing actions like
193+
`crm_action(..., action="send")` (which emails a customer) — so a model can't
194+
delete a record or send an invoice by accident.
161195
- **Tokens are revocable.** Revoke a PAT from the CRM (Settings → API Tokens) to
162196
immediately cut off an agent. Tokens may also carry an expiry.
163197
- **Never commit a token.** Keep `BCRM_TOKEN` out of source control, logs, and

0 commit comments

Comments
 (0)