Skip to content

Commit 206226b

Browse files
dcramerclaude
andcommitted
feat(middleware): Add AI agent friendly responses for unauthenticated requests
Add middleware that detects AI agents accessing frontend UI routes (via Accept: text/markdown header) and returns helpful guidance instead of HTML. The response includes: - MCP server configuration with context-aware URLs - REST API documentation links - Token generation instructions The middleware extracts org/project context from the URL path to provide pre-configured MCP server URLs. Fixes GH-106334 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7594efb commit 206226b

File tree

3 files changed

+277
-0
lines changed

3 files changed

+277
-0
lines changed

src/sentry/conf/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ def env(
383383
"django.contrib.sessions.middleware.SessionMiddleware",
384384
"django.middleware.csrf.CsrfViewMiddleware",
385385
"sentry.middleware.auth.AuthenticationMiddleware",
386+
"sentry.middleware.ai_agent.AIAgentMiddleware",
386387
"sentry.middleware.integrations.IntegrationControlMiddleware",
387388
"sentry.hybridcloud.apigateway.middleware.ApiGatewayMiddleware",
388389
"sentry.middleware.demo_mode_guard.DemoModeGuardMiddleware",

src/sentry/middleware/ai_agent.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import re
5+
from typing import Any
6+
7+
from django.http import HttpRequest, HttpResponse
8+
9+
from sentry.utils import json
10+
11+
logger = logging.getLogger(__name__)
12+
13+
# Patterns to extract org and project from URL paths
14+
ORG_PATTERN = re.compile(r"^/organizations/([^/]+)/")
15+
PROJECT_PATTERN = re.compile(r"^/organizations/([^/]+)/projects/([^/]+)/")
16+
17+
18+
def _build_mcp_config(org_slug: str | None, project_slug: str | None) -> dict[str, Any]:
19+
"""Build the MCP server configuration object."""
20+
path_segments = ["https://mcp.sentry.dev/mcp"]
21+
if org_slug:
22+
path_segments.append(org_slug)
23+
if project_slug:
24+
path_segments.append(project_slug)
25+
26+
return {
27+
"mcpServers": {
28+
"sentry": {
29+
"url": "/".join(path_segments),
30+
"headers": {"Authorization": "Bearer <token>"},
31+
}
32+
}
33+
}
34+
35+
36+
def _extract_org_project_from_path(path: str) -> tuple[str | None, str | None]:
37+
"""Extract organization and project slugs from a URL path."""
38+
if project_match := PROJECT_PATTERN.match(path):
39+
return project_match.group(1), project_match.group(2)
40+
if org_match := ORG_PATTERN.match(path):
41+
return org_match.group(1), None
42+
return None, None
43+
44+
45+
def _build_ai_agent_guidance(request: HttpRequest) -> str:
46+
"""Build context-aware guidance based on the request path."""
47+
org_slug, project_slug = _extract_org_project_from_path(request.path)
48+
49+
mcp_config = _build_mcp_config(org_slug, project_slug)
50+
mcp_config_json = json.dumps(mcp_config, indent=2)
51+
52+
return f"""\
53+
# Hey, you've hit the Sentry web UI
54+
55+
This URL serves HTML for humans in browsers. You probably want one of these instead:
56+
57+
## MCP Server (recommended for AI agents)
58+
59+
Add to your MCP client config:
60+
61+
```json
62+
{mcp_config_json}
63+
```
64+
65+
Get a token: https://sentry.io/settings/account/api/auth-tokens/
66+
67+
More info: https://mcp.sentry.dev
68+
69+
## REST API (for scripts and integrations)
70+
71+
```
72+
curl https://sentry.io/api/0/projects/ \\
73+
-H "Authorization: Bearer <token>"
74+
```
75+
76+
Docs: https://docs.sentry.io/api/
77+
"""
78+
79+
80+
def _accepts_markdown(request: HttpRequest) -> bool:
81+
"""Check if Accept header contains a markdown content type."""
82+
accept = request.META.get("HTTP_ACCEPT", "").lower()
83+
return "text/markdown" in accept or "text/x-markdown" in accept
84+
85+
86+
def _is_anonymous_request(request: HttpRequest) -> bool:
87+
"""Check if the request is anonymous (no authenticated user, no auth token)."""
88+
if getattr(request, "auth", None) is not None:
89+
return False
90+
91+
user = getattr(request, "user", None)
92+
return user is None or user.is_anonymous
93+
94+
95+
class AIAgentMiddleware:
96+
"""
97+
Middleware that intercepts unauthenticated frontend requests from AI agents
98+
and returns helpful markdown guidance instead of HTML.
99+
100+
Detection criteria:
101+
1. Request path does NOT start with /api/ (frontend routes only)
102+
2. Accept header contains text/markdown or text/x-markdown
103+
3. Request is anonymous (no authenticated user, no auth token)
104+
"""
105+
106+
def __init__(self, get_response):
107+
self.get_response = get_response
108+
109+
def __call__(self, request: HttpRequest) -> HttpResponse:
110+
# Skip API routes - only intercept frontend UI routes
111+
if request.path.startswith("/api/"):
112+
return self.get_response(request)
113+
114+
if not _accepts_markdown(request):
115+
return self.get_response(request)
116+
117+
if not _is_anonymous_request(request):
118+
return self.get_response(request)
119+
120+
logger.info(
121+
"ai_agent.guidance_served",
122+
extra={
123+
"path": request.path,
124+
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
125+
},
126+
)
127+
128+
guidance = _build_ai_agent_guidance(request)
129+
return HttpResponse(guidance, content_type="text/markdown", status=200)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from django.contrib.auth.models import AnonymousUser
4+
from django.test import RequestFactory
5+
6+
from sentry.middleware.ai_agent import AIAgentMiddleware, _accepts_markdown, _is_anonymous_request
7+
from sentry.testutils.cases import TestCase
8+
9+
10+
class AcceptsMarkdownTest(TestCase):
11+
def setUp(self):
12+
super().setUp()
13+
self.factory = RequestFactory()
14+
15+
def test_accepts_text_markdown(self):
16+
request = self.factory.get("/", HTTP_ACCEPT="text/markdown")
17+
assert _accepts_markdown(request) is True
18+
19+
def test_accepts_text_x_markdown(self):
20+
request = self.factory.get("/", HTTP_ACCEPT="text/x-markdown")
21+
assert _accepts_markdown(request) is True
22+
23+
def test_rejects_text_plain(self):
24+
request = self.factory.get("/", HTTP_ACCEPT="text/plain")
25+
assert _accepts_markdown(request) is False
26+
27+
def test_rejects_application_json(self):
28+
request = self.factory.get("/", HTTP_ACCEPT="application/json")
29+
assert _accepts_markdown(request) is False
30+
31+
def test_no_accept_header(self):
32+
request = self.factory.get("/")
33+
assert _accepts_markdown(request) is False
34+
35+
def test_case_insensitive(self):
36+
request = self.factory.get("/", HTTP_ACCEPT="TEXT/MARKDOWN")
37+
assert _accepts_markdown(request) is True
38+
39+
40+
class IsAnonymousRequestTest(TestCase):
41+
def setUp(self):
42+
super().setUp()
43+
self.factory = RequestFactory()
44+
45+
def test_anonymous_user_no_auth(self):
46+
request = self.factory.get("/")
47+
request.user = AnonymousUser()
48+
request.auth = None
49+
assert _is_anonymous_request(request) is True
50+
51+
def test_anonymous_user_with_auth(self):
52+
request = self.factory.get("/")
53+
request.user = AnonymousUser()
54+
request.auth = MagicMock()
55+
assert _is_anonymous_request(request) is False
56+
57+
def test_authenticated_user(self):
58+
request = self.factory.get("/")
59+
request.user = self.create_user()
60+
request.auth = None
61+
assert _is_anonymous_request(request) is False
62+
63+
64+
class AIAgentMiddlewareTest(TestCase):
65+
def setUp(self):
66+
super().setUp()
67+
self.factory = RequestFactory()
68+
self.middleware = AIAgentMiddleware(get_response=lambda r: MagicMock(status_code=401))
69+
70+
def make_anonymous_request(self, path: str, **kwargs):
71+
request = self.factory.get(path, **kwargs)
72+
request.user = AnonymousUser()
73+
request.auth = None
74+
return request
75+
76+
def test_returns_guidance_for_anonymous_markdown_request(self):
77+
request = self.make_anonymous_request(
78+
"/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown"
79+
)
80+
81+
response = self.middleware(request)
82+
83+
assert response.status_code == 200
84+
assert response["Content-Type"] == "text/markdown"
85+
content = response.content.decode()
86+
assert "Hey, you've hit the Sentry web UI" in content
87+
assert "https://mcp.sentry.dev/mcp/test-org" in content
88+
89+
def test_includes_project_in_mcp_url(self):
90+
request = self.make_anonymous_request(
91+
"/organizations/my-org/projects/my-project/", HTTP_ACCEPT="text/markdown"
92+
)
93+
94+
response = self.middleware(request)
95+
96+
assert response.status_code == 200
97+
assert "https://mcp.sentry.dev/mcp/my-org/my-project" in response.content.decode()
98+
99+
def test_base_mcp_url_without_org_context(self):
100+
request = self.make_anonymous_request("/settings/", HTTP_ACCEPT="text/markdown")
101+
102+
response = self.middleware(request)
103+
104+
assert response.status_code == 200
105+
assert "https://mcp.sentry.dev/mcp" in response.content.decode()
106+
107+
def test_authenticated_user_passes_through(self):
108+
request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown")
109+
request.user = self.create_user()
110+
request.auth = None
111+
112+
assert self.middleware(request).status_code == 401
113+
114+
def test_auth_token_passes_through(self):
115+
request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown")
116+
request.user = AnonymousUser()
117+
request.auth = MagicMock()
118+
119+
assert self.middleware(request).status_code == 401
120+
121+
def test_non_markdown_accept_passes_through(self):
122+
request = self.make_anonymous_request("/organizations/test-org/", HTTP_ACCEPT="text/html")
123+
124+
assert self.middleware(request).status_code == 401
125+
126+
def test_api_path_passes_through(self):
127+
request = self.make_anonymous_request("/api/0/projects/", HTTP_ACCEPT="text/markdown")
128+
129+
assert self.middleware(request).status_code == 401
130+
131+
@patch("sentry.middleware.ai_agent.logger.info")
132+
def test_logs_request(self, mock_logger: MagicMock):
133+
request = self.make_anonymous_request(
134+
"/organizations/test-org/",
135+
HTTP_ACCEPT="text/markdown",
136+
HTTP_USER_AGENT="Claude-Code/1.0",
137+
)
138+
139+
self.middleware(request)
140+
141+
mock_logger.assert_called_once_with(
142+
"ai_agent.guidance_served",
143+
extra={
144+
"path": "/organizations/test-org/",
145+
"user_agent": "Claude-Code/1.0",
146+
},
147+
)

0 commit comments

Comments
 (0)