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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/routes/(app)/settings/api-tokens/+page.server.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'import' is only available in ES6 (use 'esversion: 6').

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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'const' is available in ES6 (use 'esversion: 6') or Mozilla JS extensions (use moz).


Comment on lines +5 to +10
/** @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' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected ':' and instead saw 'message'.
Expected '}' to match '{' from line 18 and instead saw 'Failed to load tokens'.
Expected an identifier and instead saw '.'.
Expected an identifier and instead saw '||'.
Missing semicolon.
Unnecessary semicolon.

}
}

Expand Down
127 changes: 116 additions & 11 deletions frontend/src/routes/(app)/settings/api-tokens/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -33,26 +36,70 @@
/** @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": "<your CRM 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) {
// Reset the create form after a successful creation.
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 = '';
Expand All @@ -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';
Expand Down Expand Up @@ -305,19 +364,65 @@
<ChevronRight class="h-4 w-4 text-[var(--text-secondary)]" />
{/if}
<KeyRound class="h-4 w-4 text-[var(--text-secondary)]" />
<span class="text-base font-medium text-[var(--text-primary)]">
Connect your AI (Claude Desktop)
</span>
<span class="text-base font-medium text-[var(--text-primary)]"> Connect your AI </span>
</button>
{#if helpOpen}
<div class="space-y-3 border-t border-[var(--border-default)] p-4">
<!-- Client picker -->
<div class="flex flex-wrap gap-2">
{#each CLIENTS as client (client.id)}
<Button
type="button"
size="sm"
variant={selectedClient === client.id ? 'default' : 'outline'}
onclick={() => (selectedClient = client.id)}
>
{client.label}
</Button>
{/each}
</div>

<p class="text-sm text-[var(--text-secondary)]">
Add the following to your Claude Desktop MCP config, replacing
<code class="font-mono text-xs">BCRM_BASE_URL</code> with your CRM URL and
<code class="font-mono text-xs">BCRM_TOKEN</code> with the token you just created.
Add this to
<code class="font-mono text-xs text-[var(--text-primary)]">{selectedClientMeta.file}</code
>, then restart {selectedClientMeta.label}.
{#if form?.created?.token}
The token below is yours — it's shown only this once.
{:else}
Replace <code class="font-mono text-xs">BCRM_TOKEN</code> with a token you created above.
{/if}
</p>

<div class="relative">
<Button
type="button"
size="sm"
variant="outline"
class="absolute right-2 top-2 gap-1"
onclick={() => copyConfig(configSnippet)}
>
{#if configCopied}
<Check class="h-3.5 w-3.5" />
Copied
{:else}
<Copy class="h-3.5 w-3.5" />
Copy
{/if}
</Button>
<pre
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>
</div>

<p class="text-xs text-[var(--text-secondary)]">
Requires <a
href="https://docs.astral.sh/uv/"
target="_blank"
rel="noopener noreferrer"
class="underline">uv</a
>
(provides <code class="font-mono">uvx</code>) on your machine. The agent acts as you and
inherits your role — it can't see or do anything you can't.
Comment on lines +423 to +424
</p>
<pre
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>
</div>
{/if}
</section>
Expand Down
54 changes: 44 additions & 10 deletions mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
{
Expand All @@ -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
{
Expand All @@ -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
Expand All @@ -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. |

Expand All @@ -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

Expand All @@ -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
Expand Down
Loading