Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
15e927671a feat(backend): add E2E tests for Sentry and Linear MCP servers
Agent-Logs-Url: https://github.com/Significant-Gravitas/AutoGPT/sessions/13698a3a-13fc-445f-a0e9-62a2fa878ef5

Co-authored-by: ntindle <8845353+ntindle@users.noreply.github.com>
2026-04-01 11:33:06 +00:00
Zamil Majdy
1750c833ee fix(frontend): upgrade Docker Node.js from v21 (EOL) to v22 LTS (#12561)
## Summary
Upgrade the frontend **Docker image** from **Node.js v21** (EOL since
June 2024) to **Node.js v22 LTS** (supported through April 2027).

> **Scope:** This only affects the **Dockerfile** used for local
development (`docker compose`) and CI. It does **not** affect Vercel
(which manages its own Node.js runtime) or Kubernetes (the frontend Helm
chart was removed in Dec 2025 — the frontend is deployed exclusively via
Vercel).

## Why
- Node v21.7.3 has a **known TransformStream race condition bug**
causing `TypeError: controller[kState].transformAlgorithm is not a
function` — this is
[BUILDER-3KF](https://significant-gravitas.sentry.io/issues/BUILDER-3KF)
with **567,000+ Sentry events**
- The error is entirely in Node.js internals
(`node:internal/webstreams/transformstream`), zero first-party code
- Node 21 is **not an LTS release** and has been EOL since June 2024
- `package.json` already declares `"engines": { "node": "22.x" }` — the
Dockerfile was inconsistent
- Node 22.x LTS (v22.22.1) fixes the TransformStream bug
- Next.js 15.4.x requires Node 18.18+, so Node 22 is fully compatible

## Changes
- `autogpt_platform/frontend/Dockerfile`: `node:21-alpine` →
`node:22.22-alpine3.23` (both `base` and `prod` stages)

## Test plan
- [ ] Verify frontend Docker image builds successfully via `docker
compose`
- [ ] Verify frontend starts and serves pages correctly in local Docker
environment
- [ ] Monitor Sentry for BUILDER-3KF — should drop to zero for
Docker-based runs
2026-03-27 13:11:23 +07:00
5 changed files with 144 additions and 71 deletions

View File

@@ -1,9 +1,12 @@
"""
End-to-end tests against a real public MCP server.
End-to-end tests against real public and authenticated MCP servers.
These tests hit the OpenAI docs MCP server (https://developers.openai.com/mcp)
which is publicly accessible without authentication and returns SSE responses.
These tests hit live MCP servers and require network access:
- OpenAI docs (https://developers.openai.com/mcp) — no auth required
- Sentry (https://mcp.sentry.dev/mcp) — requires SENTRY_MCP_TOKEN env var
- Linear (https://mcp.linear.app/mcp) — requires LINEAR_MCP_TOKEN env var
All tests are skipped unless the respective environment variables are set.
Mark: These are tagged with ``@pytest.mark.e2e`` so they can be run/skipped
independently of the rest of the test suite (they require network access).
"""
@@ -18,6 +21,10 @@ from backend.blocks.mcp.client import MCPClient
# Public MCP server that requires no authentication
OPENAI_DOCS_MCP_URL = "https://developers.openai.com/mcp"
# Authenticated MCP servers
SENTRY_MCP_URL = "https://mcp.sentry.dev/mcp"
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"
# Skip all tests in this module unless RUN_E2E env var is set
pytestmark = pytest.mark.skipif(
not os.environ.get("RUN_E2E"), reason="set RUN_E2E=1 to run e2e tests"
@@ -107,3 +114,120 @@ class TestRealMCPServer:
tools = await client.list_tools()
assert len(tools) > 0
assert all(hasattr(t, "name") for t in tools)
def _assert_has_relevant_tool(tool_names: set[str], keywords: list[str], server: str) -> None:
"""Assert that at least one tool name contains one of the expected keywords."""
assert any(
kw in name.lower() for name in tool_names for kw in keywords
), f"Expected a tool containing {keywords} on {server}, got: {sorted(tool_names)}"
class TestSentryMCPServer:
"""Tests against the live Sentry MCP server (https://mcp.sentry.dev/mcp).
Requires SENTRY_MCP_TOKEN to be set to a valid Sentry OAuth access token.
Skipped automatically when the token is not present.
"""
@pytest.fixture
def sentry_token(self) -> str:
token = os.environ.get("SENTRY_MCP_TOKEN")
if not token:
pytest.skip("SENTRY_MCP_TOKEN not set")
return token
@pytest.mark.asyncio(loop_scope="session")
async def test_initialize(self, sentry_token):
"""Verify we can complete the MCP handshake with Sentry."""
client = MCPClient(SENTRY_MCP_URL, auth_token=sentry_token)
result = await client.initialize()
assert "protocolVersion" in result
assert "serverInfo" in result
assert "tools" in result.get("capabilities", {})
@pytest.mark.asyncio(loop_scope="session")
async def test_list_tools(self, sentry_token):
"""Verify we can discover tools from the Sentry MCP server."""
client = MCPClient(SENTRY_MCP_URL, auth_token=sentry_token)
await client.initialize()
tools = await client.list_tools()
assert len(tools) >= 1
tool_names = {t.name for t in tools}
_assert_has_relevant_tool(
tool_names, ["issue", "event", "error"], "mcp.sentry.dev"
)
for tool in tools:
assert tool.name
assert isinstance(tool.input_schema, dict)
@pytest.mark.asyncio(loop_scope="session")
async def test_sse_response_handling(self, sentry_token):
"""Verify the client handles SSE responses from the Sentry server."""
client = MCPClient(SENTRY_MCP_URL, auth_token=sentry_token)
result = await client.initialize()
assert isinstance(result, dict)
assert "protocolVersion" in result
tools = await client.list_tools()
assert len(tools) > 0
assert all(hasattr(t, "name") for t in tools)
class TestLinearMCPServer:
"""Tests against the live Linear MCP server (https://mcp.linear.app/mcp).
Requires LINEAR_MCP_TOKEN to be set to a valid Linear OAuth access token.
Skipped automatically when the token is not present.
"""
@pytest.fixture
def linear_token(self) -> str:
token = os.environ.get("LINEAR_MCP_TOKEN")
if not token:
pytest.skip("LINEAR_MCP_TOKEN not set")
return token
@pytest.mark.asyncio(loop_scope="session")
async def test_initialize(self, linear_token):
"""Verify we can complete the MCP handshake with Linear."""
client = MCPClient(LINEAR_MCP_URL, auth_token=linear_token)
result = await client.initialize()
assert "protocolVersion" in result
assert "serverInfo" in result
assert "tools" in result.get("capabilities", {})
@pytest.mark.asyncio(loop_scope="session")
async def test_list_tools(self, linear_token):
"""Verify we can discover tools from the Linear MCP server."""
client = MCPClient(LINEAR_MCP_URL, auth_token=linear_token)
await client.initialize()
tools = await client.list_tools()
assert len(tools) >= 1
tool_names = {t.name for t in tools}
_assert_has_relevant_tool(
tool_names, ["issue", "project", "team"], "mcp.linear.app"
)
for tool in tools:
assert tool.name
assert isinstance(tool.input_schema, dict)
@pytest.mark.asyncio(loop_scope="session")
async def test_sse_response_handling(self, linear_token):
"""Verify the client handles SSE responses from the Linear server."""
client = MCPClient(LINEAR_MCP_URL, auth_token=linear_token)
result = await client.initialize()
assert isinstance(result, dict)
assert "protocolVersion" in result
tools = await client.list_tools()
assert len(tools) > 0
assert all(hasattr(t, "name") for t in tools)

View File

@@ -15,6 +15,7 @@ Use these URLs directly without asking the user:
|---|---|
| Notion | `https://mcp.notion.com/mcp` |
| Linear | `https://mcp.linear.app/mcp` |
| Sentry | `https://mcp.sentry.dev/mcp` |
| Stripe | `https://mcp.stripe.com` |
| Intercom | `https://mcp.intercom.com/mcp` |
| Cloudflare | `https://mcp.cloudflare.com/mcp` |

View File

@@ -38,24 +38,12 @@ def test_sdk_exports_message_types():
def test_sdk_exports_content_block_types():
from claude_agent_sdk import TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock
from claude_agent_sdk import TextBlock, ToolResultBlock, ToolUseBlock
for cls in (TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock):
for cls in (TextBlock, ToolResultBlock, ToolUseBlock):
assert inspect.isclass(cls), f"{cls.__name__} is not a class"
def test_thinking_block_has_required_fields():
"""ThinkingBlock must have thinking + signature for API integrity on resume."""
import dataclasses
from claude_agent_sdk import ThinkingBlock
assert dataclasses.is_dataclass(ThinkingBlock)
field_names = {f.name for f in dataclasses.fields(ThinkingBlock)}
assert "thinking" in field_names, "ThinkingBlock missing 'thinking' field"
assert "signature" in field_names, "ThinkingBlock missing 'signature' field"
def test_sdk_exports_mcp_helpers():
from claude_agent_sdk import create_sdk_mcp_server, tool

View File

@@ -10,7 +10,6 @@ import shutil
import sys
import time
import uuid
import dataclasses
from collections.abc import AsyncGenerator, AsyncIterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, NamedTuple, cast
@@ -596,9 +595,7 @@ def _format_sdk_content_blocks(blocks: list) -> list[dict[str, Any]]:
"""Convert SDK content blocks to transcript format.
Handles TextBlock, ToolUseBlock, ToolResultBlock, and ThinkingBlock.
Unknown block types are preserved via dataclass/dict passthrough so that
the Anthropic API's integrity checks pass on session resume (thinking and
redacted_thinking blocks must be byte-identical to the original response).
Unknown block types are logged and skipped.
"""
result: list[dict[str, Any]] = []
for block in blocks or []:
@@ -623,55 +620,18 @@ def _format_sdk_content_blocks(blocks: list) -> list[dict[str, Any]]:
tool_result_entry["is_error"] = True
result.append(tool_result_entry)
elif isinstance(block, ThinkingBlock):
# Preserve ALL fields exactly as received — the Anthropic API
# validates that thinking blocks are unmodified on session resume.
# Using dataclasses.asdict() ensures any future fields added to
# ThinkingBlock are automatically preserved.
entry = dataclasses.asdict(block)
entry["type"] = "thinking"
result.append(entry)
else:
# Preserve unknown block types (e.g. redacted_thinking) via
# generic passthrough rather than dropping them — dropped blocks
# cause index shifts that break the API's thinking block
# integrity check on resume.
if dataclasses.is_dataclass(block) and not isinstance(block, type):
entry = dataclasses.asdict(block)
if "type" not in entry:
# Derive type from class name: RedactedThinkingBlock
# -> redacted_thinking, etc.
cls_name = type(block).__name__
entry["type"] = re.sub(
r"(?<=[a-z])(?=[A-Z])", "_", cls_name.removesuffix("Block")
).lower()
result.append(entry)
logger.info(
"[SDK] Preserved unknown block type via passthrough: %s",
type(block).__name__,
)
elif hasattr(block, "__dict__"):
entry = {
k: v
for k, v in block.__dict__.items()
if not k.startswith("_")
result.append(
{
"type": "thinking",
"thinking": block.thinking,
"signature": block.signature,
}
if "type" not in entry:
cls_name = type(block).__name__
entry["type"] = re.sub(
r"(?<=[a-z])(?=[A-Z])", "_", cls_name.removesuffix("Block")
).lower()
result.append(entry)
logger.info(
"[SDK] Preserved unknown block type via __dict__: %s",
type(block).__name__,
)
else:
logger.warning(
"[SDK] Dropping unserializable content block type: %s. "
"This may break session resume if this block type is "
"subject to API integrity checks.",
type(block).__name__,
)
)
else:
logger.warning(
f"[SDK] Unknown content block type: {type(block).__name__}. "
f"This may indicate a new SDK version with additional block types."
)
return result

View File

@@ -1,5 +1,5 @@
# Base stage for both dev and prod
FROM node:21-alpine AS base
FROM node:22.22-alpine3.23 AS base
WORKDIR /app
RUN corepack enable
COPY autogpt_platform/frontend/package.json autogpt_platform/frontend/pnpm-lock.yaml ./
@@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_SOURCEMAPS="false"
RUN if [ "$NEXT_PUBLIC_PW_TEST" = "true" ]; then NEXT_PUBLIC_PW_TEST=true NODE_OPTIONS="--max-old-space-size=8192" pnpm build; else NODE_OPTIONS="--max-old-space-size=8192" pnpm build; fi
# Prod stage - based on NextJS reference Dockerfile https://github.com/vercel/next.js/blob/64271354533ed16da51be5dce85f0dbd15f17517/examples/with-docker/Dockerfile
FROM node:21-alpine AS prod
FROM node:22.22-alpine3.23 AS prod
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
WORKDIR /app