commit
e320dfcf11
32 changed files with 4998 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
39
backend/common/migrations/0027_personalaccesstoken.py
Normal file
39
backend/common/migrations/0027_personalaccesstoken.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
91
backend/common/pat_auth.py
Normal file
91
backend/common/pat_auth.py
Normal 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",
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
backend/common/tests/test_pat_api.py
Normal file
139
backend/common/tests/test_pat_api.py
Normal 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
|
||||
147
backend/common/tests/test_pat_auth.py
Normal file
147
backend/common/tests/test_pat_auth.py
Normal 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"
|
||||
)
|
||||
56
backend/common/tests/test_pat_integration.py
Normal file
56
backend/common/tests/test_pat_integration.py
Normal 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)
|
||||
59
backend/common/tests/test_pat_model.py
Normal file
59
backend/common/tests/test_pat_model.py
Normal 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
|
||||
|
|
@ -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()),
|
||||
|
|
|
|||
78
backend/common/views/pat_views.py
Normal file
78
backend/common/views/pat_views.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
231
docs/plans/2026-06-01-mcp-server-design.md
Normal file
231
docs/plans/2026-06-01-mcp-server-design.md
Normal 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.
|
||||
1217
docs/plans/2026-06-01-mcp-server-phase1.md
Normal file
1217
docs/plans/2026-06-01-mcp-server-phase1.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
325
frontend/src/routes/(app)/settings/api-tokens/+page.svelte
Normal file
325
frontend/src/routes/(app)/settings/api-tokens/+page.svelte
Normal 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
221
mcp_server/README.md
Normal 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
20
mcp_server/pyproject.toml
Normal 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"]
|
||||
1
mcp_server/src/bcrm_mcp/__init__.py
Normal file
1
mcp_server/src/bcrm_mcp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
40
mcp_server/src/bcrm_mcp/client.py
Normal file
40
mcp_server/src/bcrm_mcp/client.py
Normal 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)
|
||||
16
mcp_server/src/bcrm_mcp/config.py
Normal file
16
mcp_server/src/bcrm_mcp/config.py
Normal 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)
|
||||
30
mcp_server/src/bcrm_mcp/entities.py
Normal file
30
mcp_server/src/bcrm_mcp/entities.py
Normal 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
|
||||
107
mcp_server/src/bcrm_mcp/server.py
Normal file
107
mcp_server/src/bcrm_mcp/server.py
Normal 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()
|
||||
226
mcp_server/src/bcrm_mcp/tools.py
Normal file
226
mcp_server/src/bcrm_mcp/tools.py
Normal 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
|
||||
0
mcp_server/tests/__init__.py
Normal file
0
mcp_server/tests/__init__.py
Normal file
26
mcp_server/tests/test_client.py
Normal file
26
mcp_server/tests/test_client.py
Normal 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)
|
||||
13
mcp_server/tests/test_entities.py
Normal file
13
mcp_server/tests/test_entities.py
Normal 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")
|
||||
46
mcp_server/tests/test_server_registration.py
Normal file
46
mcp_server/tests/test_server_registration.py
Normal 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
|
||||
106
mcp_server/tests/test_tools.py
Normal file
106
mcp_server/tests/test_tools.py
Normal 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
1519
mcp_server/uv.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue