Merge pull request #694 from MicroPyramid/dev

Dev
This commit is contained in:
Ashwin Kumar 2026-06-01 20:01:37 +05:30 committed by GitHub
commit e320dfcf11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 4998 additions and 2 deletions

View file

@ -42,6 +42,15 @@ class GetProfileAndOrg:
request.profile = None
request.org = None
# Personal Access Token (agent / MCP) — resolve here so org context is
# set before RequireOrgContext runs (DRF auth runs too late for that).
# MUST come before the JWT branch so a PAT bearer is never handed to
# the JWT decoder.
raw_pat = self._extract_pat(request)
if raw_pat:
self._process_pat_auth(request, raw_pat)
return
# Try JWT token first (primary authentication)
if request.headers.get("Authorization"):
self._process_jwt_auth(request)
@ -53,6 +62,42 @@ class GetProfileAndOrg:
self._process_api_key_auth(request, api_key)
return
def _extract_pat(self, request):
"""Return a bcrm_pat_-prefixed token from the request, else None.
Reuses the same extractor as the DRF auth class so the detection logic
(Authorization: Bearer … or the Token header) lives in one place.
"""
from common.pat_auth import _extract_raw
return _extract_raw(request)
def _process_pat_auth(self, request, raw):
"""Resolve a PAT and set org context, mirroring the JWT/org-key paths.
On an invalid/revoked/expired PAT we leave request.org unset so that
RequireOrgContext returns a clean 403 (the same denial the org-key path
produces for an unknown key once that exception surfaces). We swallow
AuthenticationFailed here rather than re-raising so the request is
denied cleanly downstream instead of 500-ing inside middleware.
"""
from rest_framework.exceptions import AuthenticationFailed
from common.pat_auth import resolve_valid_pat
try:
pat = resolve_valid_pat(raw)
except AuthenticationFailed:
# Leave org unset → RequireOrgContext denies with 403. The DRF
# PATAuthentication class will also raise on this token, but the
# middleware-level denial happens first.
return
request.profile = pat.profile
request.org = pat.org
request.META["org"] = str(pat.org.id)
request.META["mcp_token_id"] = str(pat.id)
request._pat = pat
def _process_jwt_auth(self, request):
"""
Process JWT authentication and extract org context from token.

View file

@ -0,0 +1,39 @@
# Generated by Django 6.0.5 on 2026-06-01 03:47
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0026_add_user_name'),
]
operations = [
migrations.CreateModel(
name='PersonalAccessToken',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('token_prefix', models.CharField(max_length=20)),
('scopes', models.JSONField(blank=True, default=list)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('org', models.ForeignKey(help_text='Organization this record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='common.org')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='common.profile')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
options={
'db_table': 'personal_access_token',
'indexes': [models.Index(fields=['org', '-created_at'], name='personal_ac_org_id_cc57f9_idx')],
},
),
]

View file

@ -1,5 +1,7 @@
import binascii
import hashlib
import os
import secrets
import time
import uuid
@ -7,10 +9,11 @@ from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.utils.timesince import timesince
from django.utils.translation import gettext_lazy as _
from common.base import BaseModel
from common.base import BaseModel, BaseOrgModel
from common.utils import (
COUNTRIES,
CURRENCY_CODES,
@ -881,5 +884,66 @@ class CustomFieldDefinition(BaseModel):
return f"{self.target_model}.{self.key} ({self.label})"
def generate_pat_raw():
"""Return a new raw personal access token string."""
return f"bcrm_pat_{secrets.token_urlsafe(32)}"
class PersonalAccessToken(BaseOrgModel):
"""
Per-user token for programmatic/agent (MCP) access.
The agent authenticates AS `profile` and inherits that user's role,
org and RLS scope. The raw token is shown ONCE at creation and only
its SHA-256 hash is stored.
"""
profile = models.ForeignKey(
"common.Profile",
on_delete=models.CASCADE,
related_name="access_tokens",
)
name = models.CharField(max_length=255)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
token_prefix = models.CharField(max_length=20)
# NOTE: scopes are stored for forward-compatibility but are NOT enforced in
# Phase 1 — a token always inherits the owning profile's full role/permissions.
# Do not treat `scopes` as a trust boundary until enforcement lands.
scopes = models.JSONField(default=list, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "personal_access_token"
indexes = [models.Index(fields=["org", "-created_at"])]
@staticmethod
def hash_token(raw):
return hashlib.sha256(raw.encode()).hexdigest()
@classmethod
def generate(cls, profile, name, scopes=None, expires_at=None):
raw = generate_pat_raw()
pat = cls.objects.create(
org=profile.org,
profile=profile,
name=name,
token_hash=cls.hash_token(raw),
token_prefix=raw[:13],
scopes=scopes or [],
expires_at=expires_at,
created_by=profile.user,
)
return raw, pat
def is_valid(self):
if self.revoked_at is not None:
return False
if self.expires_at is not None and self.expires_at <= timezone.now():
return False
return True
# Import SecurityAuditLog so Django discovers it for migrations
from common.audit_log import SecurityAuditLog # noqa: F401,E402 # pylint: disable=unused-import

View file

@ -0,0 +1,91 @@
import logging
from django.utils import timezone
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from common.models import PersonalAccessToken
logger = logging.getLogger(__name__)
PAT_PREFIX = "bcrm_pat_"
def _extract_raw(request):
"""Pull a bcrm_pat_ token from Authorization: Bearer or Token header."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
candidate = auth[len("Bearer "):].strip()
if candidate.startswith(PAT_PREFIX):
return candidate
token = request.headers.get("Token", "")
if token.startswith(PAT_PREFIX):
return token
return None
def resolve_valid_pat(raw):
"""Look up and validate a raw PAT.
Returns the PersonalAccessToken (with profile/user/org pre-fetched) or
raises AuthenticationFailed. Shared by the GetProfileAndOrg middleware
(which must set org context before RequireOrgContext runs) and the DRF
PATAuthentication class, so the lookup/validation logic lives in one place.
"""
try:
pat = PersonalAccessToken.objects.select_related(
"profile", "profile__user", "org"
).get(token_hash=PersonalAccessToken.hash_token(raw))
except PersonalAccessToken.DoesNotExist as exc:
logger.warning("Invalid PAT attempted")
raise AuthenticationFailed("Invalid token") from exc
if not pat.is_valid():
raise AuthenticationFailed("Token revoked or expired")
if not pat.profile.is_active or not pat.org.is_active:
raise AuthenticationFailed("Token owner or org is inactive")
return pat
class PATAuthentication(BaseAuthentication):
"""Authenticate an agent AS the token's owning Profile (inherits role+org)."""
def authenticate(self, request):
raw = _extract_raw(request)
if not raw:
return None # Not a PAT — let JWT / org-key auth handle it.
# The GetProfileAndOrg middleware resolves the PAT first (so org
# context is set before RequireOrgContext runs) and stashes it on the
# request. Reuse it to avoid a second DB lookup and a double
# last_used_at write. Fall back to resolving here for any code path
# that bypasses the middleware (e.g. RequestFactory unit tests).
pat = getattr(request, "_pat", None)
if pat is None:
pat = resolve_valid_pat(raw)
profile = pat.profile
request.profile = profile
request.org = pat.org
request.META["org"] = str(pat.org.id)
request.META["mcp_token_id"] = str(pat.id)
now = timezone.now()
if pat.last_used_at is None or (now - pat.last_used_at).total_seconds() > 60:
PersonalAccessToken.objects.filter(pk=pat.pk).update(last_used_at=now)
pat.last_used_at = now
return (profile.user, pat)
class PATAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "common.pat_auth.PATAuthentication"
name = "PersonalAccessToken"
def get_security_definition(self, auto_schema):
return {
"type": "http",
"scheme": "bearer",
"description": "Personal access token (bcrm_pat_…) for agent/MCP access",
}

View file

@ -100,6 +100,11 @@ ORG_SCOPED_TABLES = [
# Approval workflows (Tier 3 approvals).
"approval_rule",
"approval",
# MCP / programmatic access
# NOTE: personal_access_token is intentionally NOT RLS-protected — it is an
# auth-bootstrap table (looked up by token_hash before any tenant context
# exists), mirroring the Org table. Isolation for token management is enforced
# by explicit org+profile filters in common/views/pat_views.py.
]
# Centralized RLS configuration

View file

@ -21,6 +21,7 @@ from common.models import (
Document,
Notification,
Org,
PersonalAccessToken,
Profile,
Tags,
Teams,
@ -890,3 +891,49 @@ class TeamswaggerCreateSerializer(serializers.ModelSerializer):
"description",
"users",
)
class PersonalAccessTokenListSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = (
"id",
"name",
"token_prefix",
"scopes",
"expires_at",
"last_used_at",
"created_at",
"revoked_at",
)
read_only_fields = fields
class PersonalAccessTokenCreateSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = ("name", "scopes", "expires_at")
def validate_name(self, value):
value = (value or "").strip()
if not value:
raise serializers.ValidationError("Name is required.")
if len(value) > 255:
raise serializers.ValidationError("Name too long (max 255).")
return value
def validate_scopes(self, value):
if value in (None, ""):
return []
if not isinstance(value, list) or not all(isinstance(s, str) for s in value):
raise serializers.ValidationError("scopes must be a list of strings.")
if len(value) > 32:
raise serializers.ValidationError("Too many scopes (max 32).")
return value
def validate_expires_at(self, value):
from django.utils import timezone
if value is not None and value <= timezone.now():
raise serializers.ValidationError("expires_at must be in the future.")
return value

View file

@ -0,0 +1,139 @@
import uuid
import pytest
from common.serializer import PersonalAccessTokenListSerializer
from common.models import PersonalAccessToken, Profile, User
@pytest.mark.django_db
def test_list_serializer_hides_secret(admin_profile):
raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli")
data = PersonalAccessTokenListSerializer(pat).data
assert "token_hash" not in data
assert data["token_prefix"] == pat.token_prefix
assert data["name"] == "cli"
assert raw not in str(data)
@pytest.fixture
def other_profile(org_a):
"""A second, distinct profile in the SAME org as admin_profile.
Used for IDOR tests: the acting user (admin_profile) must never be able
to see or revoke this profile's tokens even though they share an org.
"""
user = User.objects.create_user(email="other@test.com", password="testpass123")
return Profile.objects.create(user=user, org=org_a, role="USER", is_active=True)
@pytest.mark.django_db
class TestPATApi:
"""Auth approach: the suite already ships a JWT-backed ``admin_client``
fixture (conftest._make_authenticated_client) used by every other API
test. We reuse it so request.profile == admin_profile and HasOrgContext
is satisfied exactly as in production. The IDOR "other" tokens are minted
directly on ``other_profile`` (a different profile in the same org), so the
acting user and the token owner are genuinely distinct identities.
"""
LIST_URL = "/api/profile/tokens/"
def _detail_url(self, pk):
return f"/api/profile/tokens/{pk}/"
def test_create_returns_raw_token_once(self, admin_client, admin_profile):
resp = admin_client.post(self.LIST_URL, {"name": "Claude"}, format="json")
assert resp.status_code == 201, resp.content
body = resp.json()
assert body["token"].startswith("bcrm_pat_")
assert body["error"] is False
assert "token_hash" not in body
lst = admin_client.get(self.LIST_URL).json()
assert lst["error"] is False
assert len(lst["tokens"]) == 1
assert all("token" not in t for t in lst["tokens"])
assert all("token_hash" not in t for t in lst["tokens"])
def test_create_rejects_blank_name(self, admin_client, admin_profile):
resp = admin_client.post(self.LIST_URL, {"name": " "}, format="json")
assert resp.status_code == 400, resp.content
body = resp.json()
assert body["error"] is True
assert "name" in body["errors"]
def test_create_rejects_too_many_scopes(self, admin_client, admin_profile):
resp = admin_client.post(
self.LIST_URL, {"name": "x", "scopes": ["s"] * 100}, format="json"
)
assert resp.status_code == 400, resp.content
def test_create_rejects_past_expiry(self, admin_client, admin_profile):
resp = admin_client.post(
self.LIST_URL,
{"name": "x", "expires_at": "2020-01-01T00:00:00Z"},
format="json",
)
assert resp.status_code == 400, resp.content
def test_create_ignores_injected_protected_fields(self, admin_client, admin_profile, org_a):
# A client must not be able to set org / profile / token_hash / revoked_at.
resp = admin_client.post(
self.LIST_URL,
{
"name": "Injected",
"token_hash": "attacker-controlled",
"revoked_at": "2020-01-01T00:00:00Z",
},
format="json",
)
assert resp.status_code == 201, resp.content
pat = PersonalAccessToken.objects.get(name="Injected")
assert pat.token_hash != "attacker-controlled"
assert pat.revoked_at is None
assert pat.org == org_a
assert pat.profile == admin_profile
def test_list_only_own_tokens(self, admin_client, admin_profile, other_profile):
_, own = PersonalAccessToken.generate(profile=admin_profile, name="mine")
_, foreign = PersonalAccessToken.generate(profile=other_profile, name="theirs")
lst = admin_client.get(self.LIST_URL).json()
ids = {t["id"] for t in lst["tokens"]}
assert str(own.id) in ids
assert str(foreign.id) not in ids
assert len(lst["tokens"]) == 1
def test_cannot_revoke_others_token(self, admin_client, admin_profile, other_profile):
_, foreign = PersonalAccessToken.generate(profile=other_profile, name="theirs")
resp = admin_client.delete(self._detail_url(foreign.id))
assert resp.status_code == 404, resp.content
foreign.refresh_from_db()
assert foreign.revoked_at is None
def test_revoke_own_token(self, admin_client, admin_profile):
_, own = PersonalAccessToken.generate(profile=admin_profile, name="mine")
resp = admin_client.delete(self._detail_url(own.id))
assert resp.status_code == 200, resp.content
own.refresh_from_db()
assert own.revoked_at is not None
def test_revoke_nonexistent_uuid_is_404(self, admin_client, admin_profile):
resp = admin_client.delete(self._detail_url(uuid.uuid4()))
assert resp.status_code == 404, resp.content
def test_list_empty_for_fresh_user(self, admin_client, admin_profile):
# admin_profile has no tokens minted in this test.
resp = admin_client.get(self.LIST_URL)
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["error"] is False
assert body["tokens"] == []
def test_unauthenticated_list_is_401(self, unauthenticated_client):
resp = unauthenticated_client.get(self.LIST_URL)
# The org-context middleware short-circuits a credential-less request
# with a 403 ("Organization context is required") BEFORE DRF's auth
# layer would emit a 401. Asserting the framework's real behavior here;
# either way the unauthenticated caller is denied (no data leak).
assert resp.status_code == 403, resp.content

View file

@ -0,0 +1,147 @@
import pytest
from datetime import timedelta
from django.db import connection
from django.test import Client, RequestFactory
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from common.models import Org, PersonalAccessToken
from common.pat_auth import PATAuthentication
@pytest.mark.django_db
class TestPATAuthentication:
def setup_method(self):
self.factory = RequestFactory()
self.auth = PATAuthentication()
def test_no_header_returns_none(self):
req = self.factory.get("/api/leads/")
assert self.auth.authenticate(req) is None
def test_non_pat_bearer_returns_none(self):
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION="Bearer ey.some.jwt")
assert self.auth.authenticate(req) is None
def test_valid_pat_authenticates_as_owner(self, org_a, admin_user, admin_profile):
raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli")
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
user, _ = self.auth.authenticate(req)
assert user == admin_user
assert req.profile == admin_profile
assert req.org == org_a
assert req.META["org"] == str(org_a.id)
pat.refresh_from_db()
assert pat.last_used_at is not None
def test_revoked_pat_raises(self, admin_profile):
raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli")
pat.revoked_at = timezone.now(); pat.save()
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
with pytest.raises(AuthenticationFailed):
self.auth.authenticate(req)
def test_expired_pat_raises(self, admin_profile):
raw, _ = PersonalAccessToken.generate(
profile=admin_profile, name="cli",
expires_at=timezone.now() - timedelta(days=1),
)
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
with pytest.raises(AuthenticationFailed):
self.auth.authenticate(req)
def test_unknown_pat_raises(self):
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION="Bearer bcrm_pat_nope")
with pytest.raises(AuthenticationFailed):
self.auth.authenticate(req)
def test_inactive_profile_raises(self, admin_profile):
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
admin_profile.is_active = False; admin_profile.save()
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
with pytest.raises(AuthenticationFailed):
self.auth.authenticate(req)
def test_valid_pat_via_token_header(self, org_a, admin_user, admin_profile):
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
req = self.factory.get("/api/leads/", HTTP_TOKEN=raw)
user, _ = self.auth.authenticate(req)
assert user == admin_user
assert req.profile == admin_profile
def test_inactive_org_raises(self, org_a, admin_profile):
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
org = admin_profile.org
org.is_active = False
org.save()
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
with pytest.raises(AuthenticationFailed):
self.auth.authenticate(req)
def test_last_used_at_throttled_not_rewritten(self, admin_profile):
raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli")
req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
self.auth.authenticate(req)
pat.refresh_from_db()
first = pat.last_used_at
assert first is not None
# Second auth immediately after — within the 60s throttle window — must not rewrite
self.auth.authenticate(req)
pat.refresh_from_db()
assert pat.last_used_at == first
def _collect_lead_ids(payload):
"""Pull every lead id out of the leads-list response shape.
LeadListView returns a dict with `open_leads.open_leads[]` and
`close_leads.close_leads[]`, each entry serialized by LeadSerializer
(which includes an `id`). We scan both buckets so the assertion holds
regardless of which list a lead lands in.
"""
ids = set()
for bucket_key, inner_key in (
("open_leads", "open_leads"),
("close_leads", "close_leads"),
):
bucket = payload.get(bucket_key) or {}
for lead in bucket.get(inner_key, []) or []:
if isinstance(lead, dict) and lead.get("id") is not None:
ids.add(str(lead["id"]))
return ids
@pytest.mark.postgres_only
@pytest.mark.django_db
def test_cross_org_pat_cannot_read_other_orgs_leads(org_a, admin_profile):
"""A PAT scoped to org A must never surface org B's leads via /api/leads/.
This is the regression guard for tenant isolation through a real list
endpoint: RLS plus the explicit `org=` ORM filter must keep org B's row
out of the response. Postgres-only because RLS is enforced by the DB;
on SQLite it cleanly skips.
"""
if connection.vendor != "postgresql":
pytest.skip("RLS isolation is enforced by PostgreSQL; skipping on non-Postgres DB")
from leads.models import Lead
# Create org B and a lead in it directly, with org explicitly set.
org_b = Org.objects.create(name="Cross-Org Isolation Org B")
lead_b = Lead.objects.create(
first_name="Hidden",
last_name="Lead-OrgB",
email="hidden_orgb@test.com",
org=org_b,
)
# Mint a PAT for the org-A admin profile and call the leads list as that token.
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cross-org-cli")
client = Client()
response = client.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
assert response.status_code == 200, response.content
lead_ids = _collect_lead_ids(response.json())
assert str(lead_b.id) not in lead_ids, (
"org B's lead leaked into an org A PAT's leads list — tenant isolation broken"
)

View file

@ -0,0 +1,56 @@
"""Integration tests driving a PAT through the REAL middleware stack.
These use Django's test ``Client`` (not DRF's APIClient with forced auth) so the
request passes through GetProfileAndOrg → RequireOrgContext → DRF auth exactly
as in production. They run on SQLite (no postgres_only marker) and are the
regression guard for the bug where a valid PAT was rejected with 403 by
RequireOrgContext because org context was never set in middleware.
"""
import pytest
from django.test import Client
from django.utils import timezone
from common.models import PersonalAccessToken
@pytest.mark.django_db
class TestPATThroughMiddlewareStack:
LIST_URL = "/api/profile/tokens/"
def test_pat_request_reaches_view(self, admin_profile):
"""A valid PAT must reach the token-list view (200), NOT be 403'd by
RequireOrgContext. This is the core regression guard for the fix."""
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
client = Client()
resp = client.get(self.LIST_URL, HTTP_AUTHORIZATION=f"Bearer {raw}")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["error"] is False
assert "tokens" in body
def test_pat_can_list_leads_via_middleware(self, admin_profile):
"""Same PAT reaching a different protected view (leads list)."""
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
client = Client()
resp = client.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}")
assert resp.status_code == 200, resp.content
def test_invalid_pat_is_denied(self, admin_profile):
"""A bogus PAT must be denied (not 500, not 200)."""
client = Client()
resp = client.get(
self.LIST_URL, HTTP_AUTHORIZATION="Bearer bcrm_pat_bogus"
)
assert resp.status_code in (401, 403), resp.content
assert resp.status_code not in (200, 500)
def test_revoked_pat_is_denied_through_stack(self, admin_profile):
"""A revoked PAT must be denied through the full stack."""
raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli")
pat.revoked_at = timezone.now()
pat.save(update_fields=["revoked_at"])
client = Client()
resp = client.get(self.LIST_URL, HTTP_AUTHORIZATION=f"Bearer {raw}")
assert resp.status_code in (401, 403), resp.content
assert resp.status_code not in (200, 500)

View file

@ -0,0 +1,59 @@
import hashlib
import pytest
from django.utils import timezone
from datetime import timedelta
from common.models import PersonalAccessToken
@pytest.mark.django_db
class TestPersonalAccessToken:
def test_generate_returns_raw_token_and_persists_hash(self, org_a, admin_profile):
raw, pat = PersonalAccessToken.generate(
profile=admin_profile, name="Claude Desktop"
)
assert raw.startswith("bcrm_pat_")
assert pat.token_hash == hashlib.sha256(raw.encode()).hexdigest()
assert pat.token_hash != raw
assert pat.token_prefix and raw.startswith(pat.token_prefix)
assert pat.org == org_a
assert pat.profile == admin_profile
def test_is_valid_true_when_active(self, admin_profile):
_, pat = PersonalAccessToken.generate(profile=admin_profile, name="x")
assert pat.is_valid() is True
def test_is_valid_false_when_revoked(self, admin_profile):
_, pat = PersonalAccessToken.generate(profile=admin_profile, name="x")
pat.revoked_at = timezone.now()
pat.save()
assert pat.is_valid() is False
def test_is_valid_false_when_expired(self, admin_profile):
_, pat = PersonalAccessToken.generate(
profile=admin_profile, name="x",
expires_at=timezone.now() - timedelta(days=1),
)
assert pat.is_valid() is False
@pytest.mark.postgres_only
@pytest.mark.django_db
def test_pat_table_has_no_rls_policy():
"""personal_access_token is intentionally exempt from RLS.
It is an auth-bootstrap table looked up by token_hash before any tenant
context exists (mirroring the Org table). Isolation for token management
is enforced by explicit org+profile filters in common/views/pat_views.py,
not by a row-level security policy. Assert no policy exists on the table.
"""
from django.db import connection
if connection.vendor != "postgresql":
pytest.skip("RLS requires PostgreSQL")
with connection.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM pg_policies WHERE tablename = %s",
["personal_access_token"],
)
assert cur.fetchone()[0] == 0

View file

@ -31,6 +31,10 @@ from common.views.organization_views import (
)
from common.views.settings_views import DomainDetailView, DomainList
from common.views.org_settings_views import OrgSettingsView
from common.views.pat_views import (
PersonalAccessTokenDetailView,
PersonalAccessTokenListCreateView,
)
from common.views.tags_views import TagsDetailView, TagsListView, TagsRestoreView
from common.views.team_views import TeamsDetailView, TeamsListView
from common.views.user_views import (
@ -67,6 +71,17 @@ urlpatterns = [
path("org/settings/", OrgSettingsView.as_view(), name="org_settings"),
path("org/<str:pk>/", OrgUpdateView.as_view()),
path("profile/", ProfileView.as_view()),
# Personal Access Tokens (MCP server) — a user manages ONLY their own
path(
"profile/tokens/",
PersonalAccessTokenListCreateView.as_view(),
name="pat_list_create",
),
path(
"profile/tokens/<uuid:pk>/",
PersonalAccessTokenDetailView.as_view(),
name="pat_detail",
),
# User management
path("users/get-teams-and-users/", GetTeamsAndUsersView.as_view()),
path("users/", UsersListView.as_view()),

View file

@ -0,0 +1,78 @@
"""Personal Access Token CRUD for the BottleCRM MCP server.
A user manages ONLY their own tokens. Both list and revoke are filtered by
``org=request.profile.org`` AND ``profile=request.profile`` so another user's
token id 404s rather than leaking or being revoked (IDOR guard). The raw token
is returned exactly once, on create; ``token_hash`` is never serialized.
"""
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from common.models import PersonalAccessToken
from common.permissions import HasOrgContext
from common.serializer import (
PersonalAccessTokenCreateSerializer,
PersonalAccessTokenListSerializer,
)
class PersonalAccessTokenListCreateView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(tags=["MCP / Tokens"], operation_id="pat_list")
def get(self, request):
qs = PersonalAccessToken.objects.filter(
org=request.profile.org, profile=request.profile
).order_by("-created_at")
return Response(
{
"error": False,
"tokens": PersonalAccessTokenListSerializer(qs, many=True).data,
},
status=status.HTTP_200_OK,
)
@extend_schema(
tags=["MCP / Tokens"],
operation_id="pat_create",
request=PersonalAccessTokenCreateSerializer,
)
def post(self, request):
ser = PersonalAccessTokenCreateSerializer(data=request.data)
if not ser.is_valid():
return Response(
{"error": True, "errors": ser.errors},
status=status.HTTP_400_BAD_REQUEST,
)
raw, pat = PersonalAccessToken.generate(
profile=request.profile,
name=ser.validated_data["name"],
scopes=ser.validated_data.get("scopes", []),
expires_at=ser.validated_data.get("expires_at"),
)
data = PersonalAccessTokenListSerializer(pat).data
data["token"] = raw # shown ONCE, never retrievable again
return Response({"error": False, **data}, status=status.HTTP_201_CREATED)
class PersonalAccessTokenDetailView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
@extend_schema(tags=["MCP / Tokens"], operation_id="pat_revoke")
def delete(self, request, pk):
pat = get_object_or_404(
PersonalAccessToken,
pk=pk,
org=request.profile.org,
profile=request.profile,
)
if pat.revoked_at is None:
pat.revoked_at = timezone.now()
pat.save(update_fields=["revoked_at"])
return Response({"error": False}, status=status.HTTP_200_OK)

View file

@ -246,6 +246,7 @@ SETTINGS_EXPORT = ["APPLICATION_NAME"]
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "rest_framework.views.exception_handler",
"DEFAULT_AUTHENTICATION_CLASSES": (
"common.pat_auth.PATAuthentication",
"rest_framework_simplejwt.authentication.JWTAuthentication",
"common.external_auth.APIKeyAuthentication",
# "rest_framework.authentication.SessionAuthentication",

View file

@ -0,0 +1,231 @@
# BottleCRM MCP Server — Design
**Date:** 2026-06-01
**Status:** Approved design (pre-implementation)
**Repo:** `Django-CRM` (community edition; enterprise inherits via the `opensource/` symlink)
## Goal
Let users connect BottleCRM to their AI agents / LLMs (Claude Desktop, custom
agents, etc.) through a Model Context Protocol (MCP) server, with full CRUD +
actions across the main CRM entities — without ever weakening the existing
security model.
## Decisions (locked)
| Question | Decision |
| --- | --- |
| Transport | **Both, phased** — stdio first, then remote Streamable HTTP. One tool layer, two entry points. |
| Data access | **Over the REST API** — MCP is a thin DRF client; never touches the DB or ORM directly. |
| Runtime | **Python + FastMCP** (official Python MCP SDK high-level API). |
| Write posture | **Full CRUD + actions**, with destructive ops gated behind explicit confirmation. |
| Auth / identity | **Per-user Personal Access Tokens (PATs)** — agent acts AS the issuing user, inheriting their role + org. OAuth 2.1 deferred to phase 2. |
| Tiering | **Community edition** (Django-CRM). Headline OSS "connect your AI" feature. |
| Tool surface | **Few generic, entity-parameterized tools** (~6) + schema discovery. |
## Architecture
A standalone Python **FastMCP** server that is a *thin REST client* of the
existing DRF API. It holds no database access of its own — every call goes over
HTTP to the CRM, so **RLS, permission classes, serializer validation, and tenant
scoping are enforced by DRF unchanged**. The MCP server physically cannot bypass
them. This keeps the backend as the single trust boundary, per the project's
security rules.
```
Agent / LLM
│ (MCP tool call)
▼
MCP server (FastMCP, thin client)
│ attaches PAT as Authorization header
▼
DRF API ── RLS + permissions + serializers ──► Postgres (RLS-scoped)
│
▼ compact JSON
Agent / LLM
```
### Three pieces of work (all in `Django-CRM`)
1. **`backend/` — Personal Access Tokens**
- `PersonalAccessToken` model (org-scoped, `BaseOrgModel`, added to
`ORG_SCOPED_TABLES` + a new RLS migration via `get_enable_policy_sql(...)`).
- `PATAuthentication` DRF auth class (registered ahead of the org-key class).
- Token CRUD API to mint / list / revoke.
2. **`mcp_server/` — new top-level uv package**
- The FastMCP app exposing the generic tool set.
- Depends only on `fastmcp`, `httpx`, `pydantic` — **not** Django. Independently
publishable, lightweight.
- Transport-agnostic tool layer; stdio and Streamable HTTP are two entry points.
3. **`frontend/` — token management UI**
- Settings page ("API Tokens / Connect your AI"): create, copy-once, revoke PATs.
- Generated connection snippets (Claude Desktop JSON, `claude mcp add`).
## Authentication & token model
### `PersonalAccessToken` (in `common/`, org-scoped)
| Field | Notes |
| --- | --- |
| `id` | uuid |
| `org` | FK, RLS-scoped |
| `user` / `profile` | whose identity the agent assumes |
| `name` | human label, e.g. "Claude Desktop" |
| `token_hash` | SHA-256 of the raw token — raw token never stored |
| `token_prefix` | first ~8 chars, shown in the list for identification |
| `scopes` | optional JSON; default = full inheritance of the profile's role |
| `expires_at` | nullable |
| `last_used_at` | throttled write |
| `created_at`, `revoked_at` | lifecycle |
- Raw token format: `bcrm_pat_<random>`. Shown **once** at creation, never retrievable again.
### PAT resolution: middleware + `PATAuthentication` (DRF)
> **Architecture correction (post-implementation):** org context is set by the
> `GetProfileAndOrg` **middleware**, which runs *before* the `RequireOrgContext`
> middleware and *before* DRF authentication. A DRF auth class sets
> `request.org` too late — `RequireOrgContext` would already have returned 403.
> So PAT resolution lives in **both** layers:
>
> - **`GetProfileAndOrg` middleware** detects a `bcrm_pat_` token (Bearer or
> `Token:` header), resolves it via the shared `resolve_valid_pat()` helper,
> and sets `request.profile` / `request.org` / `request.META["org"]` (mirroring
> the existing org-key path, which is *also* in this middleware — that's why
> the org-key path works). This is what makes `RequireOrgContext` pass and the
> RLS session variable get set from the token's org.
> - **`PATAuthentication` (DRF)** reuses the middleware-resolved token
> (`request._pat`) to set `request.user` for DRF's `IsAuthenticated`, and does
> the throttled `last_used_at` write (exactly once).
- Reads `Authorization: Bearer bcrm_pat_…` (also accepts the existing `Token:` header style for parity).
- Hashes the presented token; looks up a non-revoked, non-expired row scoped to the **real user's** role instead of always-admin.
- Any failure → denied (403 from `RequireOrgContext`, or 401 from DRF), logged **without** the token value. Never 200, never 500.
**RLS note:** the `personal_access_token` table is intentionally **not**
RLS-protected — it is an auth-bootstrap table looked up by `token_hash` *before*
any tenant context exists (the same reason the `Org`/`api_key` table is not
RLS-protected). Tenant/user isolation for token *management* is enforced by the
explicit `org=… , profile=request.profile` filters in the CRUD views (IDOR-tested).
Business tables (leads, contacts, …) remain RLS-protected unchanged.
**Why per-user matters:** because the agent inherits the owner's role,
authorization comes "for free" from the existing RBAC — a sales rep's agent is
already constrained to what the rep can do. No new authz logic in the MCP layer
to get wrong.
### Token CRUD API (`/api/profile/tokens/`)
- `POST` create → returns raw token **once**.
- `GET` list → prefixes + metadata only (never the raw token).
- `DELETE` revoke → immediate (next call fails auth).
- A user manages **only their own** tokens. Raw token never logged or re-served.
## Tool surface
Six generic, entity-parameterized tools. `entity` is an enum mapping to a REST
resource (`leads`, `contacts`, `accounts`, `opportunities`, `tasks`, `cases`,
`invoices`, `solutions`, …).
| Tool | Annotation | Purpose |
| --- | --- | --- |
| `crm_search(entity, query?, filters?, limit, offset)` | readOnly | List/filter/search. Returns **compact** rows (id + key fields), not full objects — protects token budget. |
| `crm_get(entity, id)` | readOnly | Full detail for one record (+ nested relations where the API provides them). |
| `crm_create(entity, data)` | — | Create; `data` validated by the DRF serializer. |
| `crm_update(entity, id, data)` | — | Partial update (PATCH semantics). |
| `crm_delete(entity, id, confirm=true)` | **destructive** | Requires explicit `confirm`; refuses without it. Soft-delete where supported. |
| `crm_action(entity, id, action, params?)` | varies | Non-CRUD verbs (convert lead, change_status, assign, add_comment). Discoverable via `list_actions`. |
Plus **`crm_describe(entity)`** — returns writable/readable fields + enums for an
entity, derived at runtime from the **drf-spectacular OpenAPI schema** already
published at `/schema/`. The LLM learns valid fields/values without hardcoding;
new entities/fields surface automatically.
### Guardrails in the tools
- Destructive ops gated behind `confirm`, flagged `destructiveHint`.
- `limit` capped (e.g. ≤50) with pagination cursors — prevents giant dumps and bulk exfil-in-one-call.
- Tool errors are the DRF errors surfaced cleanly (400 validation, 403 role-forbidden, 404) so the agent self-corrects.
## Transports, entity registry & deployment
### Entity registry (the only CRM-specific routing)
```python
ENTITIES = {
"leads": {"path": "/api/leads/", "actions": ["convert", "add_comment"]},
"contacts": {"path": "/api/contacts/", "actions": ["add_comment"]},
"accounts": {"path": "/api/accounts/", "actions": [...]},
# ...
}
```
Adding an entity = one dict entry; the six generic tools + `crm_describe` pick it
up automatically. Tool *code* stays entity-agnostic.
### Two entry points, one tool layer
- **stdio** (`bcrm-mcp` console script via `uvx`/`pipx`): reads `BCRM_BASE_URL` +
`BCRM_TOKEN` from env. Drops into Claude Desktop's `mcpServers` config. **Ships first.**
- **Streamable HTTP** (`bcrm-mcp --http`, hosted e.g. at `mcp.bottlecrm.io`): PAT
travels in each request's `Authorization` header — server is **stateless and
multi-tenant**, never stores a token. Phase 2 adds OAuth on top.
### Shared HTTP client
One `httpx` wrapper centralizes base URL, auth header injection, timeouts/retries
(idempotent reads only), and DRF-status → MCP-error translation. Tools never build
URLs or headers themselves.
### Packaging
`Django-CRM/mcp_server/` as its own uv project (deps: `fastmcp`, `httpx`,
`pydantic` — not Django). Connection snippets generated in the frontend token
page and documented in `mcp_server/README.md`.
## Security, abuse controls, observability
### Security
- MCP never bypasses DRF — RLS + permission classes + serializers are the enforcement.
- PATs hashed at rest, shown once, named, revocable, expirable, `last_used_at` tracked. Never logged/echoed. Revocation immediate.
- Agent inherits the owner's role — RBAC-constrained by construction.
- Destructive tools require `confirm`; deletes prefer soft-delete.
- HTTP transport is stateless per-call auth — no token storage, no session fixation surface.
### Abuse controls
- Per-token DRF throttle (separate scope from web traffic), configurable.
- `limit` caps + pagination prevent bulk exfil and runaway token spend.
- Optional per-token entity/action allow-list (`scopes`) for least-privilege agents.
### Observability
- MCP-originated requests tagged (`X-Client: mcp` / token id) — distinguishable in audit log, attributable to a real user.
- Auth failures + destructive actions logged (without secrets).
## Testing (required)
### `backend/`
- PAT model: lifecycle, hashing, expiry, revocation.
- `PATAuthentication`: valid / expired / revoked / wrong-org → 401; inherits correct role.
- Token CRUD endpoints: happy path + "can't manage others' tokens" + raw token returned exactly once.
- `postgres_only` RLS test: a token from org A cannot read org B's data.
### `mcp_server/`
- Unit tests with a mocked httpx client: each tool's request shaping, the `confirm`
gate on delete, error translation, `limit` capping, and `crm_describe` parsing
the OpenAPI schema.
## Phasing
1. **Phase 1 (MVP):** PAT model + auth + CRUD API; FastMCP server with the 6 generic
tools + `crm_describe`; **stdio** transport; frontend token page; tests.
2. **Phase 2:** Streamable HTTP transport hosted at `mcp.bottlecrm.io`; per-token
throttling + scopes; richer named actions via `crm_action`.
3. **Phase 3:** OAuth 2.1 authorization-code flow for one-click "Connect" UX on the
hosted endpoint; MCP resources/prompts for common workflows.
## Out of scope (YAGNI for now)
- OAuth (phase 3), MCP resources/prompts (phase 3), direct ORM access, admin/user/org
management tools, enterprise-only gating.

File diff suppressed because it is too large Load diff

View file

@ -49,7 +49,8 @@
ShieldCheck,
BookOpen,
Activity,
Smartphone
Smartphone,
KeyRound
} from '@lucide/svelte';
import { Bell } from '$lib/components/notifications/index.js';
@ -1216,6 +1217,14 @@
<MessageSquareQuote class="size-4" />
<span>Macros</span>
</DropdownMenu.Item>
<DropdownMenu.Item
inset={false}
onclick={() => navigateTo('/settings/api-tokens')}
class="gap-2.5"
>
<KeyRound class="size-4" />
<span>API Tokens</span>
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Label

View file

@ -0,0 +1,57 @@
import { fail } from '@sveltejs/kit';
import { apiRequest } from '$lib/api-helpers.js';
/** @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 || [] };
} catch (err) {
console.error('Failed to load API tokens:', err);
return { tokens: [], loadError: err?.message || 'Failed to load tokens' };
}
}
/** @type {import('./$types').Actions} */
export const actions = {
create: async ({ request, cookies, locals }) => {
const form = await request.formData();
const name = String(form.get('name') || '').trim();
if (!name) return fail(400, { error: 'Name is required' });
const expiresRaw = String(form.get('expires_at') || '').trim();
/** @type {{ name: string, expires_at?: string }} */
const body = { name };
if (expiresRaw) body.expires_at = expiresRaw;
try {
const created = await apiRequest(
'/profile/tokens/',
{ method: 'POST', body },
{ cookies, org: locals?.org }
);
// Return the raw token ONCE so the page can show a copy-once panel.
return { created: { token: created.token, name: created.name } };
} catch (err) {
console.error('Failed to create API token:', err);
return fail(400, { error: err?.message || 'Failed to create token' });
}
},
revoke: async ({ request, cookies, locals }) => {
const form = await request.formData();
const id = String(form.get('id') || '');
if (!id) return fail(400, { error: 'Missing token id' });
try {
await apiRequest(
`/profile/tokens/${id}/`,
{ method: 'DELETE' },
{ cookies, org: locals?.org }
);
return { revoked: id };
} catch (err) {
console.error('Failed to revoke API token:', err);
return fail(400, { error: err?.message || 'Failed to revoke token' });
}
}
};

View file

@ -0,0 +1,325 @@
<script>
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { toast } from 'svelte-sonner';
import {
Plus,
Copy,
Check,
Trash2,
KeyRound,
ChevronDown,
ChevronRight,
TriangleAlert
} from '@lucide/svelte';
import { PageHeader } from '$lib/components/layout';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
const tokens = $derived(data.tokens || []);
let formName = $state('');
let formExpiresAt = $state('');
let creating = $state(false);
// Copy-once panel state — populated only from the create action result.
let copied = $state(false);
/** @type {string} */
let confirmingId = $state('');
let helpOpen = $state(false);
const claudeConfig = `{
"mcpServers": {
"bottlecrm": {
"command": "uvx",
"args": ["bcrm-mcp"],
"env": {
"BCRM_BASE_URL": "<your CRM URL>",
"BCRM_TOKEN": "bcrm_pat_… (paste the token you just created)"
}
}
}
}`;
$effect(() => {
if (form?.created?.token) {
// Reset the create form after a successful creation.
formName = '';
formExpiresAt = '';
copied = false;
} else if (form?.revoked) {
toast.success('Token revoked');
confirmingId = '';
} else if (form?.error) {
const message = typeof form.error === 'string' ? form.error : JSON.stringify(form.error);
toast.error(message);
}
});
/** @param {string} value */
async function copyToClipboard(value) {
try {
await navigator.clipboard.writeText(value);
copied = true;
toast.success('Token copied to clipboard');
setTimeout(() => (copied = false), 2000);
} catch {
toast.error('Could not copy — select and copy manually');
}
}
/** @param {string | null | undefined} value */
function formatDate(value) {
if (!value) return 'Never';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return 'Never';
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/** @param {any} token */
function isRevoked(token) {
return !!token.revoked_at;
}
</script>
<svelte:head>
<title>API Tokens - Settings - BottleCRM</title>
</svelte:head>
<PageHeader
title="API Tokens"
subtitle="Connect your AI agent to BottleCRM. Tokens act as you and inherit your role."
/>
<div class="flex-1 p-4 md:p-6 lg:p-8">
<div class="mx-auto max-w-4xl space-y-6">
{#if data.loadError}
<div
class="rounded-lg border border-[var(--color-danger-default)] bg-[var(--color-danger-light)] p-4 text-sm text-[var(--color-danger-default)]"
>
{data.loadError}
</div>
{/if}
<!-- Copy-once panel: only shown immediately after a create action. -->
{#if form?.created?.token}
<section
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<div class="flex items-start gap-2">
<TriangleAlert class="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div class="min-w-0 flex-1 space-y-2">
<h2 class="text-sm font-medium text-amber-900 dark:text-amber-200">
Token “{form.created.name}” created
</h2>
<p class="text-sm text-amber-800 dark:text-amber-300">
Copy this token now — you won't be able to see it again.
</p>
<div class="flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-md border border-amber-300 bg-white px-3 py-2 font-mono text-xs text-[var(--text-primary)] dark:border-amber-700 dark:bg-slate-900"
>
{form.created.token}
</code>
<Button
type="button"
size="sm"
class="gap-1"
onclick={() => copyToClipboard(form.created.token)}
>
{#if copied}
<Check class="h-4 w-4" />
Copied
{:else}
<Copy class="h-4 w-4" />
Copy
{/if}
</Button>
</div>
</div>
</div>
</section>
{/if}
<!-- Create token -->
<section class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)]">
<header class="border-b border-[var(--border-default)] p-4">
<h2 class="text-base font-medium text-[var(--text-primary)]">Create token</h2>
<p class="text-sm text-[var(--text-secondary)]">
Give the token a recognisable name. Set an optional expiry, or leave it blank for a token
that never expires.
</p>
</header>
<form
method="POST"
action="?/create"
use:enhance={() => {
creating = true;
return async ({ update }) => {
await update({ reset: false });
creating = false;
await invalidateAll();
};
}}
class="flex flex-col gap-4 p-4 sm:flex-row sm:items-end"
>
<div class="flex-1 space-y-1.5">
<Label for="name">Name *</Label>
<Input id="name" name="name" required bind:value={formName} placeholder="My AI agent" />
</div>
<div class="space-y-1.5">
<Label for="expires_at">Expires (optional)</Label>
<Input id="expires_at" name="expires_at" type="date" bind:value={formExpiresAt} />
</div>
<Button type="submit" class="gap-2" disabled={creating}>
<Plus class="h-4 w-4" />
{creating ? 'Creating…' : 'Create token'}
</Button>
</form>
</section>
<!-- Existing tokens -->
<section class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)]">
<header class="border-b border-[var(--border-default)] p-4">
<h2 class="text-base font-medium text-[var(--text-primary)]">Your tokens</h2>
<p class="text-sm text-[var(--text-secondary)]">
Tokens you have created. Revoke any token you no longer use.
</p>
</header>
{#if tokens.length === 0}
<div class="p-6 text-center text-sm text-[var(--text-secondary)]">
No tokens yet. Create one above to connect your AI agent.
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Prefix</Table.Head>
<Table.Head>Last used</Table.Head>
<Table.Head>Expires</Table.Head>
<Table.Head>Created</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tokens as token (token.id)}
<Table.Row>
<Table.Cell class="font-medium text-[var(--text-primary)]">
{token.name}
</Table.Cell>
<Table.Cell>
<code class="font-mono text-xs text-[var(--text-secondary)]">
{token.token_prefix}
</code>
</Table.Cell>
<Table.Cell class="text-sm text-[var(--text-secondary)]">
{formatDate(token.last_used_at)}
</Table.Cell>
<Table.Cell class="text-sm text-[var(--text-secondary)]">
{formatDate(token.expires_at)}
</Table.Cell>
<Table.Cell class="text-sm text-[var(--text-secondary)]">
{formatDate(token.created_at)}
</Table.Cell>
<Table.Cell>
{#if isRevoked(token)}
<Badge variant="secondary">Revoked</Badge>
{:else}
<Badge variant="outline">Active</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if !isRevoked(token)}
{#if confirmingId === token.id}
<form
method="POST"
action="?/revoke"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
await invalidateAll();
};
}}
class="inline-flex items-center gap-1"
>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm" class="gap-1">
<Check class="h-3.5 w-3.5" />
Confirm
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => (confirmingId = '')}
>
Cancel
</Button>
</form>
{:else}
<Button
type="button"
variant="ghost"
size="sm"
class="gap-1 text-[var(--color-danger-default)]"
onclick={() => (confirmingId = token.id)}
>
<Trash2 class="h-3.5 w-3.5" />
Revoke
</Button>
{/if}
{:else}
<span class="text-xs text-[var(--text-secondary)]">—</span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</section>
<!-- Help: connect your AI -->
<section class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)]">
<button
type="button"
onclick={() => (helpOpen = !helpOpen)}
class="flex w-full items-center gap-2 p-4 text-left"
>
{#if helpOpen}
<ChevronDown class="h-4 w-4 text-[var(--text-secondary)]" />
{:else}
<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>
</button>
{#if helpOpen}
<div class="space-y-3 border-t border-[var(--border-default)] p-4">
<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.
</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>
</div>
</div>

221
mcp_server/README.md Normal file
View file

@ -0,0 +1,221 @@
# BottleCRM MCP Server (`bcrm-mcp`)
`bcrm-mcp` is a standalone [Model Context Protocol](https://modelcontextprotocol.io)
server that lets an AI agent (Claude Desktop, Cursor, etc.) drive a BottleCRM
instance through its REST API.
It is a **thin HTTP client** of the CRM API — it does *not* import Django, has no
database access of its own, and runs as its own [`uv`](https://docs.astral.sh/uv/)
package. Every request is authenticated with a **Personal Access Token (PAT)**
and the agent acts **as the user who owns that token**: it inherits exactly that
user's role and org. All Row-Level Security, permission checks, and field
validation are enforced by the backend — the MCP server never bypasses tenant
isolation and adds no privileges of its own.
> **Phase 1 (this release): stdio transport only.** The agent launches
> `bcrm-mcp` as a local subprocess and talks to it over stdin/stdout. Remote
> HTTP transport and OAuth-based auth are planned for later phases.
## Requirements
- Python 3.11+
- [`uv`](https://docs.astral.sh/uv/)
- A reachable BottleCRM backend (the community Django-CRM API)
- A BottleCRM Personal Access Token (`bcrm_pat_…`)
## Install & run
```bash
cd mcp_server
uv sync
BCRM_BASE_URL=http://localhost:8000 BCRM_TOKEN=bcrm_pat_… uv run bcrm-mcp
```
`uv sync` creates `.venv/` from `pyproject.toml` + `uv.lock`. `uv run bcrm-mcp`
launches the server over stdio (it will block waiting for an MCP client on
stdin — that is expected; normally your MCP client launches it for you).
### Configuration (environment variables)
| Variable | Required | Description |
| --------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `BCRM_BASE_URL` | yes | Root URL of the CRM (the host, **not** an `/api/...` path), e.g. `http://localhost:8000`. |
| `BCRM_TOKEN` | yes | A Personal Access Token, format `bcrm_pat_…`. Sent as `Authorization: Bearer <token>` on every call. |
If either is missing the server exits immediately with an error.
## Getting a Personal Access Token
A PAT identifies the user the agent acts as. Anyone holding the token has the
same CRM access as that user, scoped to their org — treat it like a password.
### Option A — CRM web UI (recommended)
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.)*
### Option B — Django shell (local / dev)
For a quick local token, mint one directly from the backend. This prints the raw
token once:
```bash
cd ../backend && uv run python manage.py shell -c "from common.models import Profile, PersonalAccessToken; p=Profile.objects.filter(role='ADMIN', is_active=True).first(); print(PersonalAccessToken.generate(p,'smoke')[0])"
```
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
Add an entry to your Claude Desktop MCP config (`claude_desktop_config.json`).
### Once the package is published
```json
{
"mcpServers": {
"bottlecrm": {
"command": "uvx",
"args": ["bcrm-mcp"],
"env": {
"BCRM_BASE_URL": "http://localhost:8000",
"BCRM_TOKEN": "bcrm_pat_…"
}
}
}
}
```
### 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:
```json
{
"mcpServers": {
"bottlecrm": {
"command": "uv",
"args": ["run", "--directory", "/abs/path/to/mcp_server", "bcrm-mcp"],
"env": {
"BCRM_BASE_URL": "http://localhost:8000",
"BCRM_TOKEN": "bcrm_pat_…"
}
}
}
}
```
(Equivalently, from inside `mcp_server` you can just run `uv run bcrm-mcp`.)
## Available tools
The server exposes **8 tools**. CRUD tools take an `entity` argument; supported
entities are: **leads, contacts, accounts, opportunities, tasks, cases,
invoices, solutions**.
| Tool | Kind | Description |
| -------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| `crm_search` | read-only | List/search records of an entity. Supports `query`, `filters`, `limit`, `offset`. `limit` is capped at **50**. |
| `crm_get` | read-only | Fetch one record's full detail by `id`. |
| `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_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. |
### Entity → actions / API path
| Entity | API path | Non-CRUD actions |
| --------------- | ------------------------- | ------------------------- |
| `leads` | `/api/leads/` | `convert`, `add_comment` |
| `contacts` | `/api/contacts/` | `add_comment` |
| `accounts` | `/api/accounts/` | `add_comment` |
| `opportunities` | `/api/opportunities/` | `add_comment` |
| `tasks` | `/api/tasks/` | — |
| `cases` | `/api/cases/` | `add_comment` |
| `invoices` | `/api/invoices/` | `send` |
| `solutions` | `/api/cases/solutions/` | — |
> Note: solutions are served under the **cases** app at `/api/cases/solutions/`,
> not at a top-level `/api/solutions/`.
## Security model
- **The agent acts as the token owner.** A PAT carries no extra privileges; the
agent can do exactly what that user can do, in that user's org, and nothing
more.
- **The backend is the only trust boundary.** All RLS (tenant isolation),
RBAC/permission checks, and input validation happen server-side. The MCP
server forwards requests and surfaces the API's responses and errors verbatim;
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.
- **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
shared configs. Pass it via environment / your MCP client's `env` block.
## Manual smoke test
To exercise the real HTTP path without the full MCP stdio handshake, run the
plain async tool functions directly against a live backend:
1. Start (or confirm) a backend on `:8000` with the PAT migrations applied:
```bash
cd ../backend
uv run python manage.py migrate common # ensures personal_access_token table exists
uv run python manage.py runserver 8000
```
2. Mint a PAT (Option B above) and copy the printed `bcrm_pat_…`.
3. Run a throwaway script against the live API:
```bash
cd ../mcp_server
BCRM_BASE_URL=http://localhost:8000 BCRM_TOKEN=bcrm_pat_… uv run python - <<'PY'
import asyncio, json, os
from bcrm_mcp.client import CrmClient
from bcrm_mcp import tools
async def main():
client = CrmClient(os.environ["BCRM_BASE_URL"], os.environ["BCRM_TOKEN"])
print(await tools.crm_search(client, "leads", limit=5))
print(json.dumps(await tools.crm_describe(client, "leads"), indent=2))
asyncio.run(main())
PY
```
`crm_search` should return a paginated list payload, and `crm_describe`
should return a non-empty field map for the `Lead` schema component.
## Development
```bash
uv sync
uv run pytest # unit tests (client, tools, entities, server registration)
uv run black . && uv run isort .
```
Layout:
```
mcp_server/
├── src/bcrm_mcp/
│ ├── server.py # FastMCP server + `bcrm-mcp` entry point (stdio)
│ ├── tools.py # the 8 tool implementations + OpenAPI describe heuristic
│ ├── client.py # async httpx wrapper (CrmClient)
│ ├── entities.py # entity → path / allowed-action registry
│ └── config.py # env-var settings (BCRM_BASE_URL, BCRM_TOKEN)
└── tests/
```

20
mcp_server/pyproject.toml Normal file
View file

@ -0,0 +1,20 @@
[project]
name = "bcrm-mcp"
version = "0.1.0"
description = "BottleCRM MCP server — connect your AI agent to BottleCRM"
requires-python = ">=3.11"
dependencies = ["fastmcp>=3.0", "httpx>=0.27", "pydantic>=2.7"]
[project.scripts]
bcrm-mcp = "bcrm_mcp.server:main"
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["src"]

View file

@ -0,0 +1 @@
__version__ = "0.1.0"

View file

@ -0,0 +1,40 @@
import httpx
class CrmError(Exception):
"""Raised on a non-2xx CRM response; message carries the DRF error."""
class CrmClient:
def __init__(self, base_url: str, token: str, timeout: float = 30.0):
self._base = base_url.rstrip("/")
self._headers = {"Authorization": f"Bearer {token}",
"X-Client": "mcp", "Accept": "application/json"}
self._timeout = timeout
async def _request(self, method, path, *, params=None, json=None):
url = f"{self._base}{path}"
async with httpx.AsyncClient(timeout=self._timeout) as c:
resp = await c.request(method, url, headers=self._headers,
params=params, json=json)
if resp.status_code >= 400:
try:
detail = resp.json()
except Exception:
detail = resp.text
raise CrmError(f"{resp.status_code}: {detail}")
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
async def get(self, path, params=None):
return await self._request("GET", path, params=params)
async def post(self, path, json):
return await self._request("POST", path, json=json)
async def patch(self, path, json):
return await self._request("PATCH", path, json=json)
async def delete(self, path):
return await self._request("DELETE", path)

View file

@ -0,0 +1,16 @@
import os
from dataclasses import dataclass
@dataclass
class Settings:
base_url: str
token: str
@classmethod
def from_env(cls):
base = os.environ.get("BCRM_BASE_URL", "").rstrip("/")
token = os.environ.get("BCRM_TOKEN", "")
if not base or not token:
raise SystemExit("BCRM_BASE_URL and BCRM_TOKEN env vars are required")
return cls(base_url=base, token=token)

View file

@ -0,0 +1,30 @@
class EntityError(ValueError):
pass
# Paths verified against backend/common/app_urls/__init__.py and each app's
# urls.py. Note: solutions are NOT served at a top-level /api/solutions/ —
# they live under the cases app at /api/cases/solutions/ (see cases/urls.py).
ENTITIES = {
"leads": {"path": "/api/leads/", "actions": ["convert", "add_comment"]},
"contacts": {"path": "/api/contacts/", "actions": ["add_comment"]},
"accounts": {"path": "/api/accounts/", "actions": ["add_comment"]},
"opportunities": {"path": "/api/opportunities/", "actions": ["add_comment"]},
"tasks": {"path": "/api/tasks/", "actions": []},
"cases": {"path": "/api/cases/", "actions": ["add_comment"]},
"invoices": {"path": "/api/invoices/", "actions": ["send"]},
"solutions": {"path": "/api/cases/solutions/", "actions": []},
}
# Actions with an outward-facing / irreversible side effect (e.g. emailing a
# customer). Like crm_delete, these require an explicit confirm=True so an agent
# can't trigger them off a misread instruction. Keyed by action name across all
# entities — keep it small and conservative.
CONFIRM_REQUIRED_ACTIONS = {"send"}
def resolve_path(entity, pk=None):
if entity not in ENTITIES:
raise EntityError(f"Unknown entity '{entity}'. Known: {', '.join(ENTITIES)}")
base = ENTITIES[entity]["path"]
return f"{base}{pk}/" if pk else base

View file

@ -0,0 +1,107 @@
"""FastMCP server entry point (stdio) for BottleCRM.
Wraps the plain async tool functions in ``bcrm_mcp.tools`` as FastMCP tools and
exposes a ``build_server(client=None)`` factory plus a ``main()`` console entry
point (the ``bcrm-mcp`` script points at ``bcrm_mcp.server:main``).
The real :class:`CrmClient` is built lazily on first tool call so that
constructing the server (e.g. in tests, or to introspect tools) never requires
``BCRM_BASE_URL`` / ``BCRM_TOKEN``. Pass ``client=`` to inject one.
"""
from fastmcp import FastMCP
from bcrm_mcp import tools
from bcrm_mcp.client import CrmClient
from bcrm_mcp.config import Settings
# Tool annotation hints (mcp.types.ToolAnnotations fields). Passed as plain
# dicts to ``@mcp.tool(annotations=...)`` — FastMCP 3.x accepts a dict and
# coerces it to a ToolAnnotations model.
_READ_ONLY = {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": True}
_WRITE = {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True}
_DESTRUCTIVE = {"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}
def build_server(client=None):
"""Build and return a FastMCP server with all CRM tools registered.
``client`` may be injected (tests pass a stub); when ``None`` a real
:class:`CrmClient` is constructed lazily from env on the first tool call.
"""
mcp = FastMCP("BottleCRM")
_client = client
def get_client():
nonlocal _client
if _client is None:
s = Settings.from_env()
_client = CrmClient(s.base_url, s.token)
return _client
@mcp.tool(annotations=_READ_ONLY)
async def crm_search(
entity: str,
query: str = "",
filters: dict | None = None,
limit: int = 20,
offset: int = 0,
):
"""Search/list CRM records of an entity (leads, contacts, accounts, opportunities, tasks, cases, invoices, solutions). Returns compact rows; limit is capped at 50."""
return await tools.crm_search(
get_client(), entity, query or None, filters, limit, offset
)
@mcp.tool(annotations=_READ_ONLY)
async def crm_get(entity: str, id: str):
"""Fetch one CRM record's full detail by id."""
return await tools.crm_get(get_client(), entity, id)
@mcp.tool(annotations=_WRITE)
async def crm_create(entity: str, data: dict):
"""Create a CRM record. `data` is validated server-side by the API."""
return await tools.crm_create(get_client(), entity, data)
@mcp.tool(annotations=_WRITE)
async def crm_update(entity: str, id: str, data: dict):
"""Partially update a CRM record (PATCH semantics)."""
return await tools.crm_update(get_client(), entity, id, data)
@mcp.tool(annotations=_DESTRUCTIVE)
async def crm_delete(entity: str, id: str, confirm: bool = False):
"""Delete a CRM record. DESTRUCTIVE and irreversible — you must pass confirm=true to proceed."""
return await tools.crm_delete(get_client(), entity, id, confirm)
@mcp.tool(annotations=_WRITE)
async def crm_action(
entity: str,
id: str,
action: str,
params: dict | None = None,
confirm: bool = False,
):
"""Run a non-CRUD action (e.g. convert a lead, add_comment, send an invoice). Call list_actions to see allowed actions per entity. Outward-facing actions like `send` (emails a customer) require confirm=true."""
return await tools.crm_action(
get_client(), entity, id, action, params, confirm
)
@mcp.tool(annotations=_READ_ONLY)
async def crm_describe(entity: str):
"""Describe an entity's writable/readable fields and enums (from the live API schema)."""
return await tools.crm_describe(get_client(), entity)
@mcp.tool(annotations=_READ_ONLY)
def list_actions():
"""Return the allowed non-CRUD actions for each entity."""
return tools.list_actions()
return mcp
def main():
"""Console entry point — runs the server over stdio (default transport)."""
build_server().run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,226 @@
from bcrm_mcp.entities import (
CONFIRM_REQUIRED_ACTIONS,
ENTITIES,
EntityError,
resolve_path,
)
MAX_LIMIT = 50
async def crm_search(client, entity, query=None, filters=None, limit=20, offset=0):
"""Search/list records of an entity. Returns compact rows."""
params = dict(filters or {})
if query:
params["search"] = query
params["limit"] = min(int(limit or 20), MAX_LIMIT)
params["offset"] = max(int(offset or 0), 0)
return await client.get(resolve_path(entity), params=params)
async def crm_get(client, entity, id):
"""Fetch a single record's full detail."""
return await client.get(resolve_path(entity, id))
async def crm_create(client, entity, data):
"""Create a record. `data` is validated server-side by DRF."""
return await client.post(resolve_path(entity), json=data)
async def crm_update(client, entity, id, data):
"""Partially update a record (PATCH)."""
return await client.patch(resolve_path(entity, id), json=data)
async def crm_delete(client, entity, id, confirm=False):
"""Delete a record. Requires confirm=True (destructive)."""
if not confirm:
raise ValueError("Destructive op: pass confirm=true to delete.")
return await client.delete(resolve_path(entity, id))
async def crm_action(client, entity, id, action, params=None, confirm=False):
"""Run a non-CRUD action (e.g. convert, add_comment). See list_actions().
Outward-facing/irreversible actions (CONFIRM_REQUIRED_ACTIONS, e.g. `send`,
which emails a customer) require confirm=True, mirroring crm_delete.
"""
if entity not in ENTITIES:
raise EntityError(f"Unknown entity '{entity}'.")
allowed = ENTITIES[entity]["actions"]
if action not in allowed:
raise ValueError(f"Action '{action}' not allowed for {entity}. Allowed: {allowed}")
if action in CONFIRM_REQUIRED_ACTIONS and not confirm:
raise ValueError(
f"Outward-facing op '{action}' has a real side effect "
f"(e.g. emails a customer): pass confirm=true to proceed."
)
return await client.post(f"{resolve_path(entity, id)}{action}/", json=params or {})
def list_actions():
"""Return the allowed actions per entity."""
return {e: cfg["actions"] for e, cfg in ENTITIES.items()}
async def crm_describe(client, entity):
"""Return writable/readable fields + enums for an entity (from the OpenAPI schema)."""
if entity not in ENTITIES:
raise EntityError(f"Unknown entity '{entity}'.")
schema = await client.get("/schema/", params={"format": "json"})
return _extract_entity_fields(schema, entity)
# --- OpenAPI schema extraction (defensive, dependency-free) -------------------
# Affixes that mark a write/request variant of a component schema. drf-spectacular
# names PATCH bodies 'Patched<Name>' (prefix) and others '<Name>Request'/'Create'
# (suffix). We use these to (a) recognise the canonical read shape vs write shapes,
# and (b) strip them when comparing a component name to an entity key.
_VARIANT_SUFFIXES = ("create", "request", "update", "list")
_VARIANT_PREFIXES = ("patched",)
def _singularize(word):
"""Crude singularizer good enough for our entity keys / component names.
'leads'->'lead', 'opportunities'->'opportunity', 'cases'->'case',
'boxes'->'box'. Only collapses 'es' to nothing when it follows a true
sibilant cluster (ss/x/z/ch/sh); otherwise just drops a trailing 's'."""
w = word.lower()
if w.endswith("ies"):
return w[:-3] + "y"
if w.endswith("es") and (
w[:-2].endswith(("ss", "x", "z", "ch", "sh"))
):
return w[:-2]
if w.endswith("s"):
return w[:-1]
return w
def _strip_variant(base):
"""Strip a leading/trailing variant affix from a lowercased name."""
for prefix in _VARIANT_PREFIXES:
if base.startswith(prefix) and len(base) > len(prefix):
return base[len(prefix):]
for suffix in _VARIANT_SUFFIXES:
if base.endswith(suffix) and len(base) > len(suffix):
return base[: -len(suffix)]
return base
def _normalize_component_name(name):
"""Lowercase a component name, strip a variant affix, then singularize.
e.g. 'LeadCreate'->'lead', 'Contacts'->'contact', 'PatchedLead'->'lead'."""
return _singularize(_strip_variant(name.lower()))
def _component_variant(name):
"""Return the variant affix found in a component name, or '' for the read
shape. Used to prefer the affix-less component as the canonical shape."""
low = name.lower()
if _strip_variant(low) != low:
for affix in _VARIANT_PREFIXES + _VARIANT_SUFFIXES:
if low.startswith(affix) or low.endswith(affix):
return affix
return ""
def _extract_entity_fields(schema, entity):
"""Walk an OpenAPI 3 dict and return a field map for `entity`.
Result shape: {field_name: {"type": str, "required": bool, "enum": [...]?}}
Returns {} (never raises) when no component matches — graceful degradation.
"""
if not isinstance(schema, dict):
return {}
components = (schema.get("components") or {}).get("schemas") or {}
if not isinstance(components, dict):
return {}
target = _singularize(entity)
# Collect components whose normalized name matches the entity.
matches = [
name
for name in components
if _normalize_component_name(name) == target
]
if not matches:
return {}
# Prefer the read shape (no variant suffix) for the field/type info; fall
# back to whatever matched. Sort so suffix-less names come first.
matches.sort(key=lambda n: (_component_variant(n) != "", n))
read_name = matches[0]
read_comp = components.get(read_name) or {}
result = {}
props = read_comp.get("properties") or {}
required = set(read_comp.get("required") or [])
for field, spec in props.items():
if not isinstance(spec, dict):
result[field] = {"type": "object", "required": field in required}
continue
entry = {
"type": _resolve_type(spec, components),
"required": field in required,
}
enum = _resolve_enum(spec, components)
if enum is not None:
entry["enum"] = enum
result[field] = entry
# Merge `required` from any write/request variant — DRF often marks fields
# required only on the create/request serializer.
for name in matches:
if name == read_name:
continue
comp = components.get(name) or {}
for field in comp.get("required") or []:
if field in result:
result[field]["required"] = True
return result
def _ref_name(ref):
"""'#/components/schemas/Foo' -> 'Foo'."""
if isinstance(ref, str) and "/" in ref:
return ref.rsplit("/", 1)[-1]
return ref
def _resolve_type(spec, components):
"""Shallow type resolution: scalar type, ref name, or 'object'."""
if "type" in spec:
return spec["type"]
if "$ref" in spec:
return _ref_name(spec["$ref"]) or "object"
if "allOf" in spec and isinstance(spec["allOf"], list):
for sub in spec["allOf"]:
if isinstance(sub, dict) and "$ref" in sub:
return _ref_name(sub["$ref"]) or "object"
return "object"
return "object"
def _resolve_enum(spec, components):
"""Return an enum list if present on the property or its (shallow) ref."""
if isinstance(spec.get("enum"), list):
return spec["enum"]
# enum sometimes lives on a referenced component (allOf -> $ref -> enum).
refs = []
if "$ref" in spec:
refs.append(spec["$ref"])
for sub in spec.get("allOf") or []:
if isinstance(sub, dict) and "$ref" in sub:
refs.append(sub["$ref"])
for ref in refs:
comp = components.get(_ref_name(ref))
if isinstance(comp, dict) and isinstance(comp.get("enum"), list):
return comp["enum"]
return None

View file

View file

@ -0,0 +1,26 @@
import httpx, pytest, respx
from bcrm_mcp.client import CrmClient, CrmError
@pytest.mark.asyncio
async def test_get_sends_bearer_and_returns_json():
client = CrmClient("https://crm.example.com", "bcrm_pat_abc")
with respx.mock:
route = respx.get("https://crm.example.com/api/leads/").mock(
return_value=httpx.Response(200, json={"results": []})
)
data = await client.get("/api/leads/")
assert data == {"results": []}
assert route.calls.last.request.headers["Authorization"] == "Bearer bcrm_pat_abc"
@pytest.mark.asyncio
async def test_400_raises_crmerror_with_detail():
client = CrmClient("https://crm.example.com", "bcrm_pat_abc")
with respx.mock:
respx.post("https://crm.example.com/api/leads/").mock(
return_value=httpx.Response(400, json={"name": ["This field is required."]})
)
with pytest.raises(CrmError) as exc:
await client.post("/api/leads/", {})
assert "required" in str(exc.value)

View file

@ -0,0 +1,13 @@
import pytest
from bcrm_mcp.entities import ENTITIES, resolve_path, EntityError
def test_known_entity_resolves_list_path():
assert resolve_path("leads") == "/api/leads/"
def test_known_entity_resolves_detail_path():
assert resolve_path("leads", "123") == "/api/leads/123/"
def test_unknown_entity_raises():
with pytest.raises(EntityError):
resolve_path("dragons")

View file

@ -0,0 +1,46 @@
from bcrm_mcp.server import build_server
EXPECTED = {
"crm_search",
"crm_get",
"crm_create",
"crm_update",
"crm_delete",
"crm_action",
"crm_describe",
"list_actions",
}
async def _registered_tool_names(mcp):
tools = await mcp.list_tools()
return {t.name for t in tools}
async def test_all_tools_registered():
# client injected so registration needs no env vars
mcp = build_server(client=object())
names = await _registered_tool_names(mcp)
assert EXPECTED <= names
async def test_build_server_without_env_or_client_does_not_raise():
# get_client() is lazy; building must not require env vars
build_server()
async def test_crm_delete_is_marked_destructive():
mcp = build_server(client=object())
tools = {t.name: t for t in await mcp.list_tools()}
delete = tools["crm_delete"]
ann = delete.annotations
assert ann is not None and ann.destructiveHint is True
assert "DESTRUCTIVE" in (delete.description or "")
async def test_read_only_tools_have_hint():
mcp = build_server(client=object())
tools = {t.name: t for t in await mcp.list_tools()}
for name in ("crm_search", "crm_get", "crm_describe", "list_actions"):
ann = tools[name].annotations
assert ann is not None and ann.readOnlyHint is True, name

View file

@ -0,0 +1,106 @@
import pytest
from bcrm_mcp import tools
class FakeClient:
def __init__(self): self.calls = []
async def get(self, path, params=None): self.calls.append(("GET", path, params)); return {"results":[{"id":"1"}]}
async def post(self, path, json): self.calls.append(("POST", path, json)); return {"id":"1"}
async def patch(self, path, json): self.calls.append(("PATCH", path, json)); return {"id":"1"}
async def delete(self, path): self.calls.append(("DELETE", path)); return {}
@pytest.mark.asyncio
async def test_search_caps_limit():
c = FakeClient()
await tools.crm_search(c, "leads", limit=999)
_, _, params = c.calls[0]
assert params["limit"] <= 50
@pytest.mark.asyncio
async def test_search_passes_query_as_search_param():
c = FakeClient()
await tools.crm_search(c, "leads", query="acme")
assert c.calls[0][2]["search"] == "acme"
@pytest.mark.asyncio
async def test_get_builds_detail_path():
c = FakeClient()
await tools.crm_get(c, "leads", "1")
assert c.calls[0] == ("GET", "/api/leads/1/", None)
@pytest.mark.asyncio
async def test_delete_requires_confirm():
c = FakeClient()
with pytest.raises(ValueError, match="confirm"):
await tools.crm_delete(c, "leads", "1", confirm=False)
assert c.calls == [] # nothing sent
@pytest.mark.asyncio
async def test_delete_with_confirm_sends_delete():
c = FakeClient()
await tools.crm_delete(c, "leads", "1", confirm=True)
assert c.calls[0] == ("DELETE", "/api/leads/1/")
@pytest.mark.asyncio
async def test_unknown_entity_rejected():
c = FakeClient()
with pytest.raises(Exception):
await tools.crm_get(c, "dragons", "1")
@pytest.mark.asyncio
async def test_action_rejects_disallowed_action():
c = FakeClient()
with pytest.raises(ValueError):
await tools.crm_action(c, "tasks", "1", "convert") # tasks has no actions
@pytest.mark.asyncio
async def test_action_allowed_posts_to_action_path():
c = FakeClient()
await tools.crm_action(c, "leads", "1", "convert", {"x": 1})
assert c.calls[0] == ("POST", "/api/leads/1/convert/", {"x": 1})
@pytest.mark.asyncio
async def test_outward_facing_action_requires_confirm():
c = FakeClient()
with pytest.raises(ValueError, match="confirm"):
await tools.crm_action(c, "invoices", "1", "send")
assert c.calls == [] # nothing sent without confirm
@pytest.mark.asyncio
async def test_outward_facing_action_with_confirm_posts():
c = FakeClient()
await tools.crm_action(c, "invoices", "1", "send", confirm=True)
assert c.calls[0] == ("POST", "/api/invoices/1/send/", {})
@pytest.mark.asyncio
async def test_non_outward_action_does_not_require_confirm():
c = FakeClient()
await tools.crm_action(c, "leads", "1", "add_comment", {"comment": "hi"})
assert c.calls[0] == ("POST", "/api/leads/1/add_comment/", {"comment": "hi"})
@pytest.mark.asyncio
async def test_list_actions_returns_map():
m = tools.list_actions()
assert m["leads"] == ["convert", "add_comment"]
def test_describe_extracts_fields():
schema = {
"components": {"schemas": {
"Lead": {
"type": "object",
"required": ["title"],
"properties": {
"title": {"type": "string"},
"status": {"type": "string", "enum": ["new", "won", "lost"]},
},
}
}}
}
out = tools._extract_entity_fields(schema, "leads")
assert out["title"]["required"] is True
assert out["status"]["enum"] == ["new", "won", "lost"]
def test_describe_unknown_component_returns_empty():
assert tools._extract_entity_fields({"components": {"schemas": {}}}, "leads") == {}

1519
mcp_server/uv.lock Normal file

File diff suppressed because it is too large Load diff