feat(seer): Thread short-lived API token to Explorer MCP tools#112179
Merged
feat(seer): Thread short-lived API token to Explorer MCP tools#112179
Conversation
950f3b1 to
33ad867
Compare
Contributor
Backend Test FailuresFailures on
|
c0db383 to
0da4983
Compare
Contributor
Backend Test FailuresFailures on
|
Contributor
Backend Test FailuresFailures on
|
JoshFerge
reviewed
Apr 9, 2026
JoshFerge
approved these changes
Apr 9, 2026
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Token created unconditionally for all callers including unrelated endpoints
- I moved token creation into a dedicated helper and only invoke it in Explorer chat paths when
enable_code_mode_toolsis enabled.
- I moved token creation into a dedicated helper and only invoke it in Explorer chat paths when
- ✅ Fixed: Auth token leaked inside user_org_context sent to Seer
collect_user_org_contextnow returns only non-sensitive context fields and no longer includesuser_auth_token.
- ✅ Fixed: Token scopes missing write permissions described in PR spec
- The Explorer auth token scopes now include the required write permissions for org, project, event, and alerts APIs.
Or push these changes by commenting:
@cursor push 4c79f312fb
Preview (4c79f312fb)
diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py
--- a/src/sentry/seer/explorer/client.py
+++ b/src/sentry/seer/explorer/client.py
@@ -20,6 +20,7 @@
ExplorerRunsRequest,
ExplorerUpdateRequest,
collect_user_org_context,
+ create_explorer_user_auth_token,
fetch_run_status,
make_explorer_chat_request,
make_explorer_runs_request,
@@ -269,6 +270,9 @@
raise ValueError("artifact_key and artifact_schema must be provided together")
user_org_context = collect_user_org_context(self.user, self.organization, request=request)
+ user_auth_token = None
+ if self.enable_code_mode_tools:
+ user_auth_token = create_explorer_user_auth_token(self.user, self.organization)
chat_body: ExplorerChatRequest = ExplorerChatRequest(
organization_id=self.organization.id,
@@ -282,7 +286,7 @@
is_interactive=self.is_interactive,
enable_coding=self.enable_coding,
enable_code_mode_tools=self.enable_code_mode_tools,
- user_auth_token=user_org_context.get("user_auth_token"),
+ user_auth_token=user_auth_token,
)
if self.max_iterations is not None:
@@ -371,7 +375,9 @@
if bool(artifact_schema) != bool(artifact_key):
raise ValueError("artifact_key and artifact_schema must be provided together")
- user_org_context = collect_user_org_context(self.user, self.organization, request=request)
+ user_auth_token = None
+ if self.enable_code_mode_tools:
+ user_auth_token = create_explorer_user_auth_token(self.user, self.organization)
chat_body: ExplorerChatRequest = ExplorerChatRequest(
organization_id=self.organization.id,
@@ -383,7 +389,7 @@
is_interactive=self.is_interactive,
enable_coding=self.enable_coding,
enable_code_mode_tools=self.enable_code_mode_tools,
- user_auth_token=user_org_context.get("user_auth_token"),
+ user_auth_token=user_auth_token,
)
if prompt_metadata:
diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py
--- a/src/sentry/seer/explorer/client_utils.py
+++ b/src/sentry/seer/explorer/client_utils.py
@@ -287,8 +287,25 @@
# Get IP address from http request, if provided
user_ip: str | None = request.META.get("REMOTE_ADDR") if request else None
- # Create a short-lived API token for Seer to call back into Sentry on behalf of the user
- user_auth_token: str | None = None
+ return {
+ "org_slug": organization.slug,
+ "user_id": user.id,
+ "user_ip": user_ip,
+ "user_name": user_name,
+ "user_email": user.email,
+ "user_timezone": user_timezone,
+ "user_teams": user_teams,
+ "user_projects": user_projects,
+ "all_org_projects": all_org_projects,
+ }
+
+
+def create_explorer_user_auth_token(
+ user: SentryUser | RpcUser | AnonymousUser | None, organization: Organization
+) -> str | None:
+ if user is None or isinstance(user, AnonymousUser):
+ return None
+
try:
from sentry.models.apitoken import ApiToken
from sentry.types.token import AuthTokenType
@@ -303,15 +320,18 @@
scoping_organization_id=organization.id,
scope_list=[
"org:read",
+ "org:write",
"project:read",
+ "project:write",
"event:read",
+ "event:write",
"alerts:read",
+ "alerts:write",
"member:read",
"team:read",
],
expires_at=timezone.now() + timedelta(hours=1),
)
- user_auth_token = token.plaintext_token
logger.info(
"seer_explorer.auth_token_created",
extra={
@@ -320,23 +340,12 @@
"expires_at": str(token.expires_at),
},
)
+ return token.plaintext_token
except Exception:
logger.exception("Failed to create short-lived API token for Seer Explorer")
+ return None
- return {
- "org_slug": organization.slug,
- "user_id": user.id,
- "user_ip": user_ip,
- "user_name": user_name,
- "user_email": user.email,
- "user_timezone": user_timezone,
- "user_teams": user_teams,
- "user_projects": user_projects,
- "all_org_projects": all_org_projects,
- "user_auth_token": user_auth_token,
- }
-
def fetch_run_status(run_id: int, organization: Organization) -> SeerRunState:
"""Fetch current run status from Seer."""
body = ExplorerStateRequest(run_id=run_id, organization_id=organization.id)
diff --git a/tests/sentry/seer/explorer/test_client_utils.py b/tests/sentry/seer/explorer/test_client_utils.py
--- a/tests/sentry/seer/explorer/test_client_utils.py
+++ b/tests/sentry/seer/explorer/test_client_utils.py
@@ -1,6 +1,7 @@
from sentry.models.organizationmember import OrganizationMember
from sentry.seer.explorer.client_utils import (
collect_user_org_context,
+ create_explorer_user_auth_token,
has_seer_explorer_access_with_detail,
snapshot_to_markdown,
)
@@ -186,7 +187,24 @@
assert context is not None
assert context.get("user_ip") == request.META.get("REMOTE_ADDR")
+ def test_collect_context_does_not_include_user_auth_token(self) -> None:
+ context = collect_user_org_context(self.user, self.organization)
+ assert "user_auth_token" not in context
+
+ def test_create_explorer_user_auth_token_includes_write_scopes(self) -> None:
+ from sentry.models.apitoken import ApiToken
+
+ token = create_explorer_user_auth_token(self.user, self.organization)
+
+ assert token is not None
+ api_token = ApiToken.objects.get(user_id=self.user.id, token=token)
+ assert "org:write" in api_token.scope_list
+ assert "project:write" in api_token.scope_list
+ assert "event:write" in api_token.scope_list
+ assert "alerts:write" in api_token.scope_list
+
+
class SnapshotToMarkdownTest(TestCase):
def test_single_node(self) -> None:
snapshot = {
diff --git a/tests/sentry/seer/explorer/test_explorer_client.py b/tests/sentry/seer/explorer/test_explorer_client.py
--- a/tests/sentry/seer/explorer/test_explorer_client.py
+++ b/tests/sentry/seer/explorer/test_explorer_client.py
@@ -79,6 +79,30 @@
@patch("sentry.seer.explorer.client.has_seer_access_with_detail")
@patch("sentry.seer.explorer.client.make_explorer_chat_request")
@patch("sentry.seer.explorer.client.collect_user_org_context")
+ @patch("sentry.seer.explorer.client.create_explorer_user_auth_token")
+ def test_start_run_only_creates_auth_token_when_code_mode_tools_enabled(
+ self, mock_create_token, mock_collect_context, mock_post, mock_access
+ ):
+ mock_access.return_value = (True, None)
+ mock_collect_context.return_value = {"user_id": self.user.id}
+ mock_create_token.return_value = "auth-token"
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"run_id": 123}
+ mock_response.status = 200
+ mock_post.return_value = mock_response
+
+ client = SeerExplorerClient(self.organization, self.user, enable_code_mode_tools=False)
+ client.start_run("Test query")
+ mock_create_token.assert_not_called()
+
+ client = SeerExplorerClient(self.organization, self.user, enable_code_mode_tools=True)
+ client.start_run("Test query")
+ assert mock_create_token.call_count == 1
+ mock_create_token.assert_called_with(self.user, self.organization)
+
+ @patch("sentry.seer.explorer.client.has_seer_access_with_detail")
+ @patch("sentry.seer.explorer.client.make_explorer_chat_request")
+ @patch("sentry.seer.explorer.client.collect_user_org_context")
def test_start_run_with_request(self, mock_collect_context, mock_post, mock_access):
"""Test starting a new run passes request object to collect_user_org_context"""
mock_access.return_value = (True, None)This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
JoshFerge
reviewed
Apr 9, 2026
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a190362. Configure here.
Create a 1-hour scoped API token (org:read, project:read, event:read) when starting an Explorer session and pass it to Seer via the user_auth_token field on ExplorerChatRequest. This enables Seer's new sentry_api_execute tool to call the Sentry API on behalf of the user. Includes instrumentation logging at each stage to trace token flow. Co-Authored-By: Claude <noreply@anthropic.com>
Add org:write, project:write, event:write, alerts:read, alerts:write, member:read, and team:read scopes to the short-lived token. The agent needs write scopes to create dashboards, alerts, and other resources on behalf of the user. Token remains org-scoped and 1-hour expiry. Co-Authored-By: Claude <noreply@anthropic.com>
The endpoint now passes request= to start_run() for auth token creation. Update the mock assertion to expect it. Co-Authored-By: Claude <noreply@anthropic.com>
…inue 1. Register organizations:seer-explorer-mcp-tools feature flag to gate general-purpose MCP tools (sentry_api_search/execute) in Explorer. 2. Thread enable_mcp_tools from the chat endpoint through SeerExplorerClient to the Seer request body. 3. Fix auth token expiry on multi-turn sessions: continue_run now calls collect_user_org_context to create a fresh token on every request, rather than relying on the token from start_run which has a 1-hour TTL. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add enable_mcp_tools=False to remaining assert_called_with calls and request=ANY to continue_run assertion. Co-Authored-By: Claude <noreply@anthropic.com>
Rename feature flag and field to reflect the code mode project naming rather than the generic MCP implementation detail. Co-Authored-By: Claude <noreply@anthropic.com>
Remove token_prefix from log lines to avoid persisting credential material in logs. Keep the token creation log for debugging but without sensitive fields. Co-Authored-By: Claude <noreply@anthropic.com>
Remove write scopes (org:write, project:write, event:write, alerts:write) from the short-lived token created for Seer Explorer. Read-only is sufficient for code mode tools for now. Co-Authored-By: Claude <noreply@anthropic.com>
- Set api_expose=False on code mode feature flag (not needed by frontend) - Strip user_auth_token from user_org_context before sending to Seer to avoid sending it twice (once in dict, once as top-level field) Co-Authored-By: Claude <noreply@anthropic.com>
Extract token creation into standalone create_explorer_api_token() function and only call it when enable_code_mode_tools is True. This avoids creating unnecessary tokens when the feature is off, and eliminates expensive collect_user_org_context queries in continue_run that were only needed for the token. Co-Authored-By: Claude <noreply@anthropic.com>
a190362 to
593d1b1
Compare
create_explorer_api_token expects a real user, but self.user can be None or AnonymousUser. Skip token creation in those cases. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Create a short-lived (1-hour), org-scoped API token when the user opens
the Seer Explorer and pass it to Seer via
user_auth_tokenon theExplorerChatRequest. This enables the new MCPsentry_api_executetool to call the Sentry API on behalf of the user.
What
ApiTokenincollect_user_org_context()with scopes:org:read/write,project:read/write,event:read/write,alerts:read/write,member:read,team:readRpcUser→ realUsermodel for token creationrequest=requestthrough tostart_run()so the tokencan be created with the authenticated user
user_auth_tokenfield toExplorerChatRequestTypedDictSecurity
scoping_organization_idCompanion PR
Seer-side changes (tool registration + threading): getsentry/seer#5626