Skip to content

Commit c662d6a

Browse files
dcramerclaude
andauthored
feat(middleware): Add AI agent friendly responses for unauthenticated requests (#106485)
Add middleware that returns helpful markdown guidance when AI agents (Claude Code, Cursor, Copilot, etc.) make unauthenticated requests to frontend UI routes. When an anonymous request with `Accept: text/markdown` or `text/x-markdown` hits a non-API route, the middleware returns a friendly response explaining how to properly access Sentry: - **MCP Server** - Pre-configured URL with org/project context extracted from the request path - **REST API** - Authentication instructions and documentation links Example response for `/organizations/my-org/projects/my-project/`: ```markdown # Hey, you've hit the Sentry web UI This URL serves HTML for humans in browsers. You probably want one of these instead: ## MCP Server (recommended for AI agents) Add to your MCP client config: { "mcpServers": { "sentry": { "url": "https://mcp.sentry.dev/mcp/my-org/my-project", } } } More info: https://mcp.sentry.dev ## REST API (for scripts and integrations) curl https://sentry.io/api/0/projects/ \ -H "Authorization: Bearer <token>" Docs: https://docs.sentry.io/api/ ``` Fixes #106334 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b39fe3 commit c662d6a

File tree

3 files changed

+234
-0
lines changed

3 files changed

+234
-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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from __future__ import annotations
2+
3+
import json # noqa: S003 - need stdlib json for indent support
4+
import logging
5+
import re
6+
from typing import Any
7+
8+
from django.http import HttpRequest, HttpResponse
9+
10+
logger = logging.getLogger(__name__)
11+
12+
PATH_PATTERN = re.compile(r"^/organizations/([^/]+)/(?:projects/([^/]+)/)?")
13+
14+
15+
def _build_mcp_config(org_slug: str | None, project_slug: str | None) -> dict[str, Any]:
16+
"""Build the MCP server configuration object."""
17+
path_segments = ["https://mcp.sentry.dev/mcp"]
18+
if org_slug:
19+
path_segments.append(org_slug)
20+
if project_slug:
21+
path_segments.append(project_slug)
22+
23+
return {
24+
"mcpServers": {
25+
"sentry": {
26+
"url": "/".join(path_segments),
27+
}
28+
}
29+
}
30+
31+
32+
def _extract_org_project_from_path(path: str) -> tuple[str | None, str | None]:
33+
"""Extract organization and project slugs from a URL path."""
34+
if match := PATH_PATTERN.match(path):
35+
return match.group(1), match.group(2)
36+
return None, None
37+
38+
39+
def _build_ai_agent_guidance(request: HttpRequest) -> str:
40+
"""Build context-aware guidance based on the request path."""
41+
org_slug, project_slug = _extract_org_project_from_path(request.path)
42+
43+
mcp_config = _build_mcp_config(org_slug, project_slug)
44+
mcp_config_json = json.dumps(mcp_config, indent=2)
45+
46+
return f"""\
47+
# This Is the Web UI
48+
49+
It's HTML all the way down. You probably don't want to parse that.
50+
51+
## MCP Server
52+
53+
OAuth-authenticated, HTTP streaming—no `<div>` soup.
54+
55+
```json
56+
{mcp_config_json}
57+
```
58+
59+
Docs: https://mcp.sentry.dev
60+
61+
## REST API
62+
63+
```
64+
curl https://sentry.io/api/0/projects/ \\
65+
-H "Authorization: Bearer <token>"
66+
```
67+
68+
Your human can get a token at https://sentry.io/settings/account/api/auth-tokens/
69+
70+
Docs: https://docs.sentry.io/api/
71+
"""
72+
73+
74+
def _accepts_markdown(request: HttpRequest) -> bool:
75+
"""Check if Accept header contains a markdown content type."""
76+
accept = request.META.get("HTTP_ACCEPT", "").lower()
77+
return "text/markdown" in accept or "text/x-markdown" in accept
78+
79+
80+
class AIAgentMiddleware:
81+
"""
82+
Middleware that intercepts unauthenticated frontend requests from AI agents
83+
and returns helpful markdown guidance instead of HTML.
84+
85+
Detection criteria:
86+
1. Request path does NOT start with /api/ (frontend routes only)
87+
2. Accept header contains text/markdown or text/x-markdown
88+
3. Request is anonymous (no authenticated user, no auth token)
89+
"""
90+
91+
def __init__(self, get_response):
92+
self.get_response = get_response
93+
94+
def __call__(self, request: HttpRequest) -> HttpResponse:
95+
# Skip API routes - only intercept frontend UI routes
96+
if request.path.startswith("/api/"):
97+
return self.get_response(request)
98+
99+
if not _accepts_markdown(request):
100+
return self.get_response(request)
101+
102+
if request.auth is not None or request.user.is_authenticated:
103+
return self.get_response(request)
104+
105+
logger.info(
106+
"ai_agent.guidance_served",
107+
extra={
108+
"path": request.path,
109+
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
110+
},
111+
)
112+
113+
guidance = _build_ai_agent_guidance(request)
114+
return HttpResponse(guidance, content_type="text/markdown", status=200)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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
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_other_types(self):
24+
request = self.factory.get("/", HTTP_ACCEPT="text/plain")
25+
assert _accepts_markdown(request) is False
26+
27+
def test_case_insensitive(self):
28+
request = self.factory.get("/", HTTP_ACCEPT="TEXT/MARKDOWN")
29+
assert _accepts_markdown(request) is True
30+
31+
def test_case_insensitive_mixed(self):
32+
request = self.factory.get("/", HTTP_ACCEPT="Text/Markdown")
33+
assert _accepts_markdown(request) is True
34+
35+
36+
class AIAgentMiddlewareTest(TestCase):
37+
def setUp(self):
38+
super().setUp()
39+
self.factory = RequestFactory()
40+
self.middleware = AIAgentMiddleware(get_response=lambda r: MagicMock(status_code=401))
41+
42+
def make_anonymous_request(self, path: str, **kwargs):
43+
request = self.factory.get(path, **kwargs)
44+
request.user = AnonymousUser()
45+
request.auth = None
46+
return request
47+
48+
def test_returns_guidance_for_anonymous_markdown_request(self):
49+
request = self.make_anonymous_request(
50+
"/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown"
51+
)
52+
53+
response = self.middleware(request)
54+
55+
assert response.status_code == 200
56+
assert response["Content-Type"] == "text/markdown"
57+
content = response.content.decode()
58+
assert "# This Is the Web UI" in content
59+
assert "https://mcp.sentry.dev/mcp/test-org" in content
60+
61+
def test_includes_project_in_mcp_url(self):
62+
request = self.make_anonymous_request(
63+
"/organizations/my-org/projects/my-project/", HTTP_ACCEPT="text/markdown"
64+
)
65+
66+
response = self.middleware(request)
67+
68+
assert response.status_code == 200
69+
assert "https://mcp.sentry.dev/mcp/my-org/my-project" in response.content.decode()
70+
71+
def test_base_mcp_url_without_org_context(self):
72+
request = self.make_anonymous_request("/settings/", HTTP_ACCEPT="text/markdown")
73+
74+
response = self.middleware(request)
75+
76+
assert response.status_code == 200
77+
assert "https://mcp.sentry.dev/mcp" in response.content.decode()
78+
79+
def test_authenticated_user_passes_through(self):
80+
request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown")
81+
request.user = self.create_user()
82+
request.auth = None
83+
84+
assert self.middleware(request).status_code == 401
85+
86+
def test_auth_token_passes_through(self):
87+
request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown")
88+
request.user = AnonymousUser()
89+
request.auth = MagicMock()
90+
91+
assert self.middleware(request).status_code == 401
92+
93+
def test_non_markdown_accept_passes_through(self):
94+
request = self.make_anonymous_request("/organizations/test-org/", HTTP_ACCEPT="text/html")
95+
96+
assert self.middleware(request).status_code == 401
97+
98+
def test_api_path_passes_through(self):
99+
request = self.make_anonymous_request("/api/0/projects/", HTTP_ACCEPT="text/markdown")
100+
101+
assert self.middleware(request).status_code == 401
102+
103+
@patch("sentry.middleware.ai_agent.logger.info")
104+
def test_logs_request(self, mock_logger: MagicMock):
105+
request = self.make_anonymous_request(
106+
"/organizations/test-org/",
107+
HTTP_ACCEPT="text/markdown",
108+
HTTP_USER_AGENT="Claude-Code/1.0",
109+
)
110+
111+
self.middleware(request)
112+
113+
mock_logger.assert_called_once_with(
114+
"ai_agent.guidance_served",
115+
extra={
116+
"path": "/organizations/test-org/",
117+
"user_agent": "Claude-Code/1.0",
118+
},
119+
)

0 commit comments

Comments
 (0)