-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(middleware): Add AI agent friendly responses for unauthenticated requests #106485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
659cc37
e5fc6cc
ef28749
77df4d2
35278ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json # noqa: S003 - need stdlib json for indent support | ||
| import logging | ||
| import re | ||
| from typing import Any | ||
|
|
||
| from django.http import HttpRequest, HttpResponse | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| PATH_PATTERN = re.compile(r"^/organizations/([^/]+)/(?:projects/([^/]+)/)?") | ||
|
|
||
|
|
||
| def _build_mcp_config(org_slug: str | None, project_slug: str | None) -> dict[str, Any]: | ||
| """Build the MCP server configuration object.""" | ||
| path_segments = ["https://mcp.sentry.dev/mcp"] | ||
| if org_slug: | ||
| path_segments.append(org_slug) | ||
| if project_slug: | ||
| path_segments.append(project_slug) | ||
|
|
||
| return { | ||
| "mcpServers": { | ||
| "sentry": { | ||
| "url": "/".join(path_segments), | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| def _extract_org_project_from_path(path: str) -> tuple[str | None, str | None]: | ||
| """Extract organization and project slugs from a URL path.""" | ||
| if match := PATH_PATTERN.match(path): | ||
| return match.group(1), match.group(2) | ||
| return None, None | ||
|
|
||
|
|
||
| def _build_ai_agent_guidance(request: HttpRequest) -> str: | ||
| """Build context-aware guidance based on the request path.""" | ||
| org_slug, project_slug = _extract_org_project_from_path(request.path) | ||
|
|
||
| mcp_config = _build_mcp_config(org_slug, project_slug) | ||
| mcp_config_json = json.dumps(mcp_config, indent=2) | ||
|
|
||
| return f"""\ | ||
| # This Is the Web UI | ||
|
|
||
| It's HTML all the way down. You probably don't want to parse that. | ||
|
|
||
| ## MCP Server | ||
|
|
||
| OAuth-authenticated, HTTP streaming—no `<div>` soup. | ||
|
|
||
| ```json | ||
| {mcp_config_json} | ||
| ``` | ||
|
|
||
| Docs: https://mcp.sentry.dev | ||
|
|
||
| ## REST API | ||
|
|
||
| ``` | ||
| curl https://sentry.io/api/0/projects/ \\ | ||
| -H "Authorization: Bearer <token>" | ||
| ``` | ||
|
|
||
| Your human can get a token at https://sentry.io/settings/account/api/auth-tokens/ | ||
|
|
||
| Docs: https://docs.sentry.io/api/ | ||
| """ | ||
|
|
||
|
|
||
| def _accepts_markdown(request: HttpRequest) -> bool: | ||
| """Check if Accept header contains a markdown content type.""" | ||
| accept = request.META.get("HTTP_ACCEPT", "").lower() | ||
| return "text/markdown" in accept or "text/x-markdown" in accept | ||
BYK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class AIAgentMiddleware: | ||
| """ | ||
| Middleware that intercepts unauthenticated frontend requests from AI agents | ||
| and returns helpful markdown guidance instead of HTML. | ||
|
|
||
| Detection criteria: | ||
| 1. Request path does NOT start with /api/ (frontend routes only) | ||
| 2. Accept header contains text/markdown or text/x-markdown | ||
| 3. Request is anonymous (no authenticated user, no auth token) | ||
| """ | ||
|
|
||
| def __init__(self, get_response): | ||
| self.get_response = get_response | ||
|
|
||
| def __call__(self, request: HttpRequest) -> HttpResponse: | ||
| # Skip API routes - only intercept frontend UI routes | ||
| if request.path.startswith("/api/"): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shall we also explicitly exclude our
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you mean the oauth stuff? i think its fine as no one should be sending a user agent that would hit this that would also hit those the only reason we do /api/ is because it actually is machines sending those requests
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of the new ones from #105675 might be worth it:
|
||
| return self.get_response(request) | ||
|
|
||
| if not _accepts_markdown(request): | ||
| return self.get_response(request) | ||
|
|
||
| if request.auth is not None or request.user.is_authenticated: | ||
sentry[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return self.get_response(request) | ||
|
|
||
| logger.info( | ||
| "ai_agent.guidance_served", | ||
| extra={ | ||
| "path": request.path, | ||
| "user_agent": request.META.get("HTTP_USER_AGENT", ""), | ||
| }, | ||
| ) | ||
|
|
||
| guidance = _build_ai_agent_guidance(request) | ||
| return HttpResponse(guidance, content_type="text/markdown", status=200) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| from django.contrib.auth.models import AnonymousUser | ||
| from django.test import RequestFactory | ||
|
|
||
| from sentry.middleware.ai_agent import AIAgentMiddleware, _accepts_markdown | ||
| from sentry.testutils.cases import TestCase | ||
|
|
||
|
|
||
| class AcceptsMarkdownTest(TestCase): | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.factory = RequestFactory() | ||
|
|
||
| def test_accepts_text_markdown(self): | ||
| request = self.factory.get("/", HTTP_ACCEPT="text/markdown") | ||
| assert _accepts_markdown(request) is True | ||
|
|
||
| def test_accepts_text_x_markdown(self): | ||
| request = self.factory.get("/", HTTP_ACCEPT="text/x-markdown") | ||
| assert _accepts_markdown(request) is True | ||
|
|
||
| def test_rejects_other_types(self): | ||
| request = self.factory.get("/", HTTP_ACCEPT="text/plain") | ||
| assert _accepts_markdown(request) is False | ||
|
|
||
| def test_case_insensitive(self): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would also add |
||
| request = self.factory.get("/", HTTP_ACCEPT="TEXT/MARKDOWN") | ||
dcramer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| assert _accepts_markdown(request) is True | ||
|
|
||
| def test_case_insensitive_mixed(self): | ||
| request = self.factory.get("/", HTTP_ACCEPT="Text/Markdown") | ||
| assert _accepts_markdown(request) is True | ||
|
|
||
|
|
||
| class AIAgentMiddlewareTest(TestCase): | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.factory = RequestFactory() | ||
| self.middleware = AIAgentMiddleware(get_response=lambda r: MagicMock(status_code=401)) | ||
|
|
||
| def make_anonymous_request(self, path: str, **kwargs): | ||
| request = self.factory.get(path, **kwargs) | ||
| request.user = AnonymousUser() | ||
| request.auth = None | ||
| return request | ||
|
|
||
| def test_returns_guidance_for_anonymous_markdown_request(self): | ||
| request = self.make_anonymous_request( | ||
| "/organizations/test-org/issues/", HTTP_ACCEPT="text/markdown" | ||
| ) | ||
|
|
||
| response = self.middleware(request) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response["Content-Type"] == "text/markdown" | ||
| content = response.content.decode() | ||
| assert "# This Is the Web UI" in content | ||
| assert "https://mcp.sentry.dev/mcp/test-org" in content | ||
|
|
||
| def test_includes_project_in_mcp_url(self): | ||
| request = self.make_anonymous_request( | ||
| "/organizations/my-org/projects/my-project/", HTTP_ACCEPT="text/markdown" | ||
| ) | ||
|
|
||
| response = self.middleware(request) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert "https://mcp.sentry.dev/mcp/my-org/my-project" in response.content.decode() | ||
|
|
||
| def test_base_mcp_url_without_org_context(self): | ||
| request = self.make_anonymous_request("/settings/", HTTP_ACCEPT="text/markdown") | ||
|
|
||
| response = self.middleware(request) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert "https://mcp.sentry.dev/mcp" in response.content.decode() | ||
|
|
||
| def test_authenticated_user_passes_through(self): | ||
| request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown") | ||
| request.user = self.create_user() | ||
| request.auth = None | ||
|
|
||
| assert self.middleware(request).status_code == 401 | ||
|
|
||
| def test_auth_token_passes_through(self): | ||
| request = self.factory.get("/organizations/test-org/", HTTP_ACCEPT="text/markdown") | ||
| request.user = AnonymousUser() | ||
| request.auth = MagicMock() | ||
|
|
||
| assert self.middleware(request).status_code == 401 | ||
|
|
||
| def test_non_markdown_accept_passes_through(self): | ||
| request = self.make_anonymous_request("/organizations/test-org/", HTTP_ACCEPT="text/html") | ||
|
|
||
| assert self.middleware(request).status_code == 401 | ||
|
|
||
| def test_api_path_passes_through(self): | ||
| request = self.make_anonymous_request("/api/0/projects/", HTTP_ACCEPT="text/markdown") | ||
|
|
||
| assert self.middleware(request).status_code == 401 | ||
|
|
||
| @patch("sentry.middleware.ai_agent.logger.info") | ||
| def test_logs_request(self, mock_logger: MagicMock): | ||
| request = self.make_anonymous_request( | ||
| "/organizations/test-org/", | ||
| HTTP_ACCEPT="text/markdown", | ||
| HTTP_USER_AGENT="Claude-Code/1.0", | ||
| ) | ||
|
|
||
| self.middleware(request) | ||
|
|
||
| mock_logger.assert_called_once_with( | ||
| "ai_agent.guidance_served", | ||
| extra={ | ||
| "path": "/organizations/test-org/", | ||
| "user_agent": "Claude-Code/1.0", | ||
| }, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.