Skip to content

Commit 41eed3a

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 41eed3a

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
}
31+
}
32+
}
33+
34+
35+
def _extract_org_project_from_path(path: str) -> tuple[str | None, str | None]:
36+
"""Extract organization and project slugs from a URL path."""
37+
if project_match := PROJECT_PATTERN.match(path):
38+
return project_match.group(1), project_match.group(2)
39+
if org_match := ORG_PATTERN.match(path):
40+
return org_match.group(1), None
41+
return None, None
42+
43+
44+
def _build_ai_agent_guidance(request: HttpRequest) -> str:
45+
"""Build context-aware guidance based on the request path."""
46+
org_slug, project_slug = _extract_org_project_from_path(request.path)
47+
48+
mcp_config = _build_mcp_config(org_slug, project_slug)
49+
mcp_config_json = json.dumps(mcp_config, indent=2)
50+
51+
return f"""\
52+
# Hey, you've hit the Sentry web UI
53+
54+
This URL serves HTML for humans in browsers. You probably want one of these instead:
55+
56+
## MCP Server (recommended for AI agents)
57+
58+
This is an HTTP streaming MCP endpoint that uses OAuth for authentication.
59+
60+
```json
61+
{mcp_config_json}
62+
```
63+
64+
More info: https://mcp.sentry.dev
65+
66+
## REST API (for scripts and integrations)
67+
68+
```
69+
curl https://sentry.io/api/0/projects/ \\
70+
-H "Authorization: Bearer <token>"
71+
```
72+
73+
Your user can generate a token at: https://sentry.io/settings/account/api/auth-tokens/
74+
75+
Docs: https://docs.sentry.io/api/
76+
"""
77+
78+
79+
def _accepts_markdown(request: HttpRequest) -> bool:
80+
"""Check if Accept header contains a markdown content type."""
81+
accept = request.META.get("HTTP_ACCEPT", "").lower()
82+
return "text/markdown" in accept or "text/x-markdown" in accept
83+
84+
85+
def _is_anonymous_request(request: HttpRequest) -> bool:
86+
"""Check if the request is anonymous (no authenticated user, no auth token)."""
87+
if getattr(request, "auth", None) is not None:
88+
return False
89+
90+
user = getattr(request, "user", None)
91+
return user is None or user.is_anonymous
92+
93+
94+
class AIAgentMiddleware:
95+
"""
96+
Middleware that intercepts unauthenticated frontend requests from AI agents
97+
and returns helpful markdown guidance instead of HTML.
98+
99+
Detection criteria:
100+
1. Request path does NOT start with /api/ (frontend routes only)
101+
2. Accept header contains text/markdown or text/x-markdown
102+
3. Request is anonymous (no authenticated user, no auth token)
103+
"""
104+
105+
def __init__(self, get_response):
106+
self.get_response = get_response
107+
108+
def __call__(self, request: HttpRequest) -> HttpResponse:
109+
# Skip API routes - only intercept frontend UI routes
110+
if request.path.startswith("/api/"):
111+
return self.get_response(request)
112+
113+
if not _accepts_markdown(request):
114+
return self.get_response(request)
115+
116+
if not _is_anonymous_request(request):
117+
return self.get_response(request)
118+
119+
logger.info(
120+
"ai_agent.guidance_served",
121+
extra={
122+
"path": request.path,
123+
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
124+
},
125+
)
126+
127+
guidance = _build_ai_agent_guidance(request)
128+
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)