mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
MCP consolidation: - Route all MCP-capable connectors (Slack, Jira, Linear, ClickUp, Airtable, Notion, Confluence, interim Gmail/Calendar, custom MCP) through a single `mcp_discovery` subagent. Drive/OneDrive/Dropbox stay native to enrich the KB. - Deprecate Discord/Teams/Luma: no viable official MCP server. Google-only web search: - Remove the main-agent `web_search` tool and the SearXNG platform service; all public web search now flows through the `google_search` subagent via task(). - Deprecate the Tavily/SearXNG/Linkup/Baidu search connectors (HTTP 410 on create, "Deprecated" badge); guide heavy users to the custom MCP connector. - Remove web search from anonymous chat (pure Q&A). - Tear SearXNG out of docker compose + install scripts; drop tavily-python and linkup-sdk deps and their config/env vars. Fix: - metrics._package_version() now swallows any metadata lookup failure. A malformed editable-install distribution with no `Version` field raised KeyError deep in importlib.metadata, and since it runs on every record_subagent_invoke_duration call it was crashing every task() delegation. Verified end-to-end against live GPT-5.4. Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
7.4 KiB
Python
198 lines
7.4 KiB
Python
"""Strict Atlassian (Jira + Confluence) MCP OAuth/tool fakes for Playwright E2E.
|
|
|
|
Jira and Confluence share one hosted Atlassian Rovo MCP server
|
|
(``https://mcp.atlassian.com/v1/mcp``) and one OAuth surface. Because the MCP
|
|
OAuth/runtime dispatchers are keyed by URL, a single handler here serves both
|
|
connectors; production keeps their tool sets disjoint via each service's
|
|
``allowed_tools`` curation. The OAuth ``redirect_uri`` substring is therefore
|
|
relaxed to the shared ``/api/v1/auth/mcp/`` prefix so both the
|
|
``.../mcp/jira/...`` and ``.../mcp/confluence/...`` callbacks validate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from tests.e2e.fakes import mcp_oauth_runtime, mcp_runtime
|
|
|
|
_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "jira_issues.json"
|
|
|
|
_AUTHORIZATION_URL = "https://mcp.atlassian.com/v1/authorize"
|
|
_REGISTRATION_URL = "https://cf.mcp.atlassian.com/v1/register"
|
|
_TOKEN_URL = "https://cf.mcp.atlassian.com/v1/token"
|
|
_MCP_URL = "https://mcp.atlassian.com/v1/mcp"
|
|
|
|
_CLIENT_ID = "fake-jira-mcp-client-id"
|
|
_CLIENT_SECRET = "fake-jira-mcp-client-secret"
|
|
_ACCESS_TOKEN = "fake-jira-mcp-access-token"
|
|
_REFRESH_TOKEN = "fake-jira-mcp-refresh-token"
|
|
_OAUTH_CODE = "fake-jira-oauth-code"
|
|
|
|
# Confluence canary — keep in sync with FAKE_CONFLUENCE_PAGES /
|
|
# CANARY_TOKENS.confluenceCanary in surfsense_web/tests/helpers/canary.ts.
|
|
_CONFLUENCE_TOKEN = "SURFSENSE_E2E_CANARY_TOKEN_CONFLUENCE_001"
|
|
_CONFLUENCE_PAGE_ID = "fake-confluence-page-canary-001"
|
|
_CONFLUENCE_TITLE = "E2E Canary Confluence Page"
|
|
_CONFLUENCE_SPACE_ID = "fake-confluence-space-001"
|
|
|
|
|
|
def _load_fixture() -> dict[str, Any]:
|
|
with _FIXTURE_PATH.open() as f:
|
|
return json.load(f)
|
|
|
|
|
|
_FIXTURE = _load_fixture()
|
|
|
|
|
|
def _issue_text(issue: dict[str, Any]) -> str:
|
|
return (
|
|
f"{issue['key']} {issue['summary']}\n"
|
|
f"id: {issue['id']}\n"
|
|
f"description: {issue['description']}"
|
|
)
|
|
|
|
|
|
async def _list_tools() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
tools=[
|
|
SimpleNamespace(
|
|
name="getAccessibleAtlassianResources",
|
|
description="Get Jira sites accessible to the authenticated Atlassian user.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": [],
|
|
},
|
|
),
|
|
SimpleNamespace(
|
|
name="searchJiraIssuesUsingJql",
|
|
description="Search Jira issues using a Jira Query Language expression.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"jql": {
|
|
"type": "string",
|
|
"description": "JQL query used to search Jira issues.",
|
|
},
|
|
"maxResults": {
|
|
"type": "integer",
|
|
"description": "Maximum number of matching issues to return.",
|
|
},
|
|
},
|
|
"required": ["jql"],
|
|
},
|
|
),
|
|
SimpleNamespace(
|
|
name="searchConfluenceUsingCql",
|
|
description="Search Confluence content using a CQL expression.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"cql": {
|
|
"type": "string",
|
|
"description": "CQL query used to search Confluence content.",
|
|
}
|
|
},
|
|
"required": ["cql"],
|
|
},
|
|
),
|
|
SimpleNamespace(
|
|
name="getConfluencePage",
|
|
description="Fetch a Confluence page's body by id.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"pageId": {
|
|
"type": "string",
|
|
"description": "The Confluence page id to fetch.",
|
|
}
|
|
},
|
|
"required": ["pageId"],
|
|
},
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
async def _call_tool(
|
|
tool_name: str, arguments: dict[str, Any] | None = None
|
|
) -> SimpleNamespace:
|
|
arguments = arguments or {}
|
|
site = _FIXTURE["site"]
|
|
issue = _FIXTURE["issues"][0]
|
|
|
|
if tool_name == "getAccessibleAtlassianResources":
|
|
if arguments:
|
|
raise ValueError(
|
|
f"Unexpected Jira getAccessibleAtlassianResources args: {arguments!r}"
|
|
)
|
|
text = f"{site['name']}\ncloud_id: {site['cloud_id']}\nurl: {site['url']}"
|
|
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
|
|
|
if tool_name == "searchJiraIssuesUsingJql":
|
|
jql = str(arguments.get("jql", ""))
|
|
if (
|
|
issue["summary"].lower() not in jql.lower()
|
|
and issue["key"].lower() not in jql.lower()
|
|
):
|
|
raise ValueError(f"Unexpected Jira JQL query: {jql!r}")
|
|
text = _issue_text(issue)
|
|
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
|
|
|
if tool_name == "searchConfluenceUsingCql":
|
|
cql = str(arguments.get("cql", ""))
|
|
if _CONFLUENCE_TITLE.lower() not in cql.lower():
|
|
raise ValueError(f"Unexpected Confluence CQL query: {cql!r}")
|
|
text = (
|
|
f"{_CONFLUENCE_TITLE}\n"
|
|
f"id: {_CONFLUENCE_PAGE_ID}\n"
|
|
f"space: {_CONFLUENCE_SPACE_ID}\n"
|
|
f"excerpt: {_CONFLUENCE_TOKEN}"
|
|
)
|
|
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
|
|
|
if tool_name == "getConfluencePage":
|
|
page_id = str(arguments.get("pageId", ""))
|
|
if page_id != _CONFLUENCE_PAGE_ID:
|
|
raise ValueError(f"Unexpected Confluence page id: {page_id!r}")
|
|
text = f"{_CONFLUENCE_TITLE}\n\n{_CONFLUENCE_TOKEN}"
|
|
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
|
|
|
raise NotImplementedError(f"Unexpected Atlassian MCP tool call: {tool_name!r}")
|
|
|
|
|
|
def install(active_patches: list[Any]) -> None:
|
|
"""Register the shared Atlassian (Jira + Confluence) MCP handlers."""
|
|
del active_patches
|
|
mcp_oauth_runtime.register_service(
|
|
mcp_url=_MCP_URL,
|
|
discovery_metadata={
|
|
"issuer": "https://mcp.atlassian.com",
|
|
"authorization_endpoint": _AUTHORIZATION_URL,
|
|
"token_endpoint": _TOKEN_URL,
|
|
"registration_endpoint": _REGISTRATION_URL,
|
|
"code_challenge_methods_supported": ["S256"],
|
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
"response_types_supported": ["code"],
|
|
},
|
|
client_id=_CLIENT_ID,
|
|
client_secret=_CLIENT_SECRET,
|
|
token_endpoint=_TOKEN_URL,
|
|
registration_endpoint=_REGISTRATION_URL,
|
|
oauth_code=_OAUTH_CODE,
|
|
access_token=_ACCESS_TOKEN,
|
|
refresh_token=_REFRESH_TOKEN,
|
|
scope="read:jira-work read:confluence-content.all read:me write:jira-work",
|
|
# Shared Atlassian server serves both Jira and Confluence connectors, so
|
|
# accept either service's callback under the common MCP OAuth prefix.
|
|
redirect_uri_substring="/api/v1/auth/mcp/",
|
|
)
|
|
mcp_runtime.register(
|
|
url=_MCP_URL,
|
|
expected_bearer=_ACCESS_TOKEN,
|
|
list_tools=_list_tools,
|
|
call_tool=_call_tool,
|
|
)
|