Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ def env(
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"sentry.middleware.auth.AuthenticationMiddleware",
"sentry.middleware.ai_agent.AIAgentMiddleware",
"sentry.middleware.integrations.IntegrationControlMiddleware",
"sentry.hybridcloud.apigateway.middleware.ApiGatewayMiddleware",
"sentry.middleware.demo_mode_guard.DemoModeGuardMiddleware",
Expand Down
114 changes: 114 additions & 0 deletions src/sentry/middleware/ai_agent.py
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


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/"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we also explicitly exclude our auth endpoints? Or maybe we can add a property on the views themselves in the future: has_markdown and only allow those?

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the new ones from #105675 might be worth it:

  • /oauth/device_authorization
  • /oauth/token
  • /oauth/device

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:
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)
119 changes: 119 additions & 0 deletions tests/sentry/middleware/test_ai_agent.py
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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would also add text/html test, just in case

request = self.factory.get("/", HTTP_ACCEPT="TEXT/MARKDOWN")
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",
},
)
Loading