Skip to content

Commit 5b57001

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 5b57001

File tree

3 files changed

+320
-0
lines changed

3 files changed

+320
-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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 _get_accepted_markdown_type(request: HttpRequest) -> str | None:
81+
"""Check if Accept header contains a markdown content type."""
82+
accept = request.META.get("HTTP_ACCEPT", "").lower()
83+
if "text/markdown" in accept:
84+
return "text/markdown"
85+
if "text/x-markdown" in accept:
86+
return "text/x-markdown"
87+
return None
88+
89+
90+
def _is_anonymous_request(request: HttpRequest) -> bool:
91+
"""Check if the request is anonymous (no authenticated user, no auth token)."""
92+
if getattr(request, "auth", None) is not None:
93+
return False
94+
95+
user = getattr(request, "user", None)
96+
return user is None or user.is_anonymous
97+
98+
99+
class AIAgentMiddleware:
100+
"""
101+
Middleware that intercepts unauthenticated frontend requests from AI agents
102+
and returns helpful markdown guidance instead of HTML.
103+
104+
Detection criteria:
105+
1. Request path does NOT start with /api/ (frontend routes only)
106+
2. Accept header contains text/markdown or text/x-markdown
107+
3. Request is anonymous (no authenticated user, no auth token)
108+
"""
109+
110+
def __init__(self, get_response):
111+
self.get_response = get_response
112+
113+
def __call__(self, request: HttpRequest) -> HttpResponse:
114+
# Skip API routes - only intercept frontend UI routes
115+
if request.path.startswith("/api/"):
116+
return self.get_response(request)
117+
118+
matched_type = _get_accepted_markdown_type(request)
119+
if not matched_type:
120+
return self.get_response(request)
121+
122+
# Check if anonymous
123+
if not _is_anonymous_request(request):
124+
return self.get_response(request)
125+
126+
logger.info(
127+
"ai_agent.guidance_served",
128+
extra={
129+
"path": request.path,
130+
"content_type": matched_type,
131+
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
132+
},
133+
)
134+
135+
guidance = _build_ai_agent_guidance(request)
136+
return HttpResponse(guidance, content_type=matched_type, status=200)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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 (
7+
AIAgentMiddleware,
8+
_get_accepted_markdown_type,
9+
_is_anonymous_request,
10+
)
11+
from sentry.testutils.cases import TestCase
12+
13+
14+
class GetAcceptedMarkdownTypeTest(TestCase):
15+
def setUp(self):
16+
super().setUp()
17+
self.factory = RequestFactory()
18+
19+
def test_accepts_text_markdown(self):
20+
request = self.factory.get("/", HTTP_ACCEPT="text/markdown")
21+
assert _get_accepted_markdown_type(request) == "text/markdown"
22+
23+
def test_accepts_text_x_markdown(self):
24+
request = self.factory.get("/", HTTP_ACCEPT="text/x-markdown")
25+
assert _get_accepted_markdown_type(request) == "text/x-markdown"
26+
27+
def test_prefers_text_markdown_over_x_markdown(self):
28+
request = self.factory.get("/", HTTP_ACCEPT="text/x-markdown, text/markdown")
29+
assert _get_accepted_markdown_type(request) == "text/markdown"
30+
31+
def test_rejects_text_plain(self):
32+
request = self.factory.get("/", HTTP_ACCEPT="text/plain")
33+
assert _get_accepted_markdown_type(request) is None
34+
35+
def test_rejects_application_json(self):
36+
request = self.factory.get("/", HTTP_ACCEPT="application/json")
37+
assert _get_accepted_markdown_type(request) is None
38+
39+
def test_no_accept_header(self):
40+
request = self.factory.get("/")
41+
assert _get_accepted_markdown_type(request) is None
42+
43+
def test_case_insensitive(self):
44+
request = self.factory.get("/", HTTP_ACCEPT="TEXT/MARKDOWN")
45+
assert _get_accepted_markdown_type(request) == "text/markdown"
46+
47+
48+
class IsAnonymousRequestTest(TestCase):
49+
def setUp(self):
50+
super().setUp()
51+
self.factory = RequestFactory()
52+
53+
def test_anonymous_user_no_auth(self):
54+
request = self.factory.get("/")
55+
request.user = AnonymousUser()
56+
request.auth = None
57+
assert _is_anonymous_request(request) is True
58+
59+
def test_anonymous_user_with_auth(self):
60+
request = self.factory.get("/")
61+
request.user = AnonymousUser()
62+
request.auth = MagicMock()
63+
assert _is_anonymous_request(request) is False
64+
65+
def test_authenticated_user(self):
66+
request = self.factory.get("/")
67+
request.user = self.create_user()
68+
request.auth = None
69+
assert _is_anonymous_request(request) is False
70+
71+
def test_no_user_attribute(self):
72+
request = self.factory.get("/")
73+
request.auth = None
74+
assert _is_anonymous_request(request) is True
75+
76+
77+
class AIAgentMiddlewareTest(TestCase):
78+
def setUp(self):
79+
super().setUp()
80+
self.factory = RequestFactory()
81+
self.middleware = AIAgentMiddleware(get_response=lambda r: MagicMock(status_code=401))
82+
83+
def make_anonymous_request(self, path: str, **kwargs):
84+
request = self.factory.get(path, **kwargs)
85+
request.user = AnonymousUser()
86+
request.auth = None
87+
return request
88+
89+
def test_anonymous_with_text_markdown_returns_guidance(self):
90+
request = self.make_anonymous_request(
91+
"/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown"
92+
)
93+
94+
response = self.middleware(request)
95+
96+
assert response.status_code == 200
97+
assert response["Content-Type"] == "text/markdown"
98+
content = response.content.decode()
99+
assert "Hey, you've hit the Sentry web UI" in content
100+
assert "https://mcp.sentry.dev/mcp/test-org" in content
101+
102+
def test_anonymous_with_text_x_markdown_returns_guidance(self):
103+
request = self.make_anonymous_request(
104+
"/organizations/test-org/issues/", HTTP_ACCEPT="text/x-markdown"
105+
)
106+
107+
response = self.middleware(request)
108+
109+
assert response.status_code == 200
110+
assert response["Content-Type"] == "text/x-markdown"
111+
112+
def test_guidance_includes_project_context(self):
113+
request = self.make_anonymous_request(
114+
"/organizations/my-org/projects/my-project/", HTTP_ACCEPT="text/markdown"
115+
)
116+
117+
response = self.middleware(request)
118+
119+
assert response.status_code == 200
120+
content = response.content.decode()
121+
assert "https://mcp.sentry.dev/mcp/my-org/my-project" in content
122+
123+
def test_guidance_without_org_context(self):
124+
request = self.make_anonymous_request("/settings/", HTTP_ACCEPT="text/markdown")
125+
126+
response = self.middleware(request)
127+
128+
assert response.status_code == 200
129+
content = response.content.decode()
130+
assert "https://mcp.sentry.dev/mcp" in content
131+
132+
def test_authenticated_passes_through(self):
133+
request = self.factory.get("/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown")
134+
request.user = self.create_user()
135+
request.auth = None
136+
137+
response = self.middleware(request)
138+
139+
assert response.status_code == 401
140+
141+
def test_auth_token_passes_through(self):
142+
request = self.factory.get("/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown")
143+
request.user = AnonymousUser()
144+
request.auth = MagicMock()
145+
146+
response = self.middleware(request)
147+
148+
assert response.status_code == 401
149+
150+
def test_json_accept_passes_through(self):
151+
request = self.make_anonymous_request(
152+
"/organizations/test-org/issues/", HTTP_ACCEPT="application/json"
153+
)
154+
155+
response = self.middleware(request)
156+
157+
assert response.status_code == 401
158+
159+
def test_api_path_passes_through(self):
160+
request = self.make_anonymous_request("/api/0/projects/", HTTP_ACCEPT="text/markdown")
161+
162+
response = self.middleware(request)
163+
164+
assert response.status_code == 401
165+
166+
@patch("sentry.middleware.ai_agent.logger.info")
167+
def test_logs_request_info(self, mock_logger: MagicMock):
168+
request = self.make_anonymous_request(
169+
"/organizations/test-org/issues/",
170+
HTTP_ACCEPT="text/markdown",
171+
HTTP_USER_AGENT="Claude-Code/1.0",
172+
)
173+
174+
self.middleware(request)
175+
176+
mock_logger.assert_called_once_with(
177+
"ai_agent.guidance_served",
178+
extra={
179+
"path": "/organizations/test-org/issues/",
180+
"content_type": "text/markdown",
181+
"user_agent": "Claude-Code/1.0",
182+
},
183+
)

0 commit comments

Comments
 (0)