Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

1412 lines
52 KiB
Python

"""Shared GitHub MCP integration helpers.
This module centralizes GitHub MCP configuration, validation, and tool calling
so the onboarding wizard, verify CLI, chat tools, and investigation actions all
use the same transport and parsing logic.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from typing import Any, Literal, cast
from urllib.parse import urlparse, urlunparse
import httpx
from mcp import ClientSession, StdioServerParameters, types # type: ignore[import-not-found]
from mcp.client.sse import sse_client # type: ignore[import-not-found]
from mcp.client.stdio import stdio_client # type: ignore[import-not-found]
from pydantic import Field, field_validator, model_validator
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
from config.strict_config import StrictConfigModel
from integrations._validation_helpers import report_classify_failure, report_validation_failure
from integrations.mcp_streamable_http_compat import streamable_http_client
from platform.terminal.theme import BRAND, DIM, ERROR, HIGHLIGHT
logger = logging.getLogger(__name__)
DEFAULT_GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"
DEFAULT_GITHUB_MCP_MODE = "streamable-http"
DEFAULT_GITHUB_MCP_TOOLSETS = ("repos", "issues", "pull_requests", "actions", "search")
# Non-transport metadata persisted alongside MCP credentials in the integration store.
_CREDENTIAL_METADATA_KEYS: frozenset[str] = frozenset({"username"})
REQUIRED_SOURCE_INVESTIGATION_TOOLS = (
"get_file_contents",
"get_repository_tree",
"list_commits",
"search_code",
)
# "auto" probe order: prefer the user's own / accessible repositories with no args.
# Hosted Copilot MCP often omits list_repositories and requires args on the others,
# so auto falls through to a ``search_repositories user:<login>`` query (the user's own
# repos). Starred repositories are intentionally excluded here — they are irrelevant for
# SRE investigations and only surfaced when ``repo_view="starred"`` is chosen explicitly.
_REPO_PROBE_NO_ARG_TOOLS: tuple[str, ...] = (
"list_repositories",
"list_user_repositories",
)
# Default cap on repos captured from one MCP list/search call (display + verify).
# Keeps responses and terminal output bounded; not a GitHub API limit. Override with
# OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT (integer, clamped 5..500).
_DEFAULT_REPO_PROBE_LIMIT = 50
_GITHUB_MCP_DISPLAY_LEVELS = frozenset({"summary", "standard", "full"})
GitHubMcpDisplayDetailLevel = Literal["summary", "standard", "full"]
GitHubMcpRepoView = Literal["auto", "user", "accessible", "starred", "search_user"]
GitHubMcpRepoVisibilityFilter = Literal["any", "public", "private"]
def _is_github_copilot_host(url: str) -> bool:
"""True when ``url`` points at GitHub's hosted Copilot MCP host.
The hosted endpoint (``api.githubcopilot.com``) always requires
authentication, so a config that targets it without a token cannot
succeed — we treat that as "not configured" rather than probing it and
surfacing a confusing ``401``.
"""
raw = (url or "").strip()
if not raw:
return False
host = urlparse(raw).netloc.lower()
if "@" in host:
host = host.split("@", 1)[-1]
host = host.split(":", 1)[0]
return host == "api.githubcopilot.com"
def _is_github_copilot_generic_mcp_root(url: str) -> bool:
"""True when ``url`` is the default Copilot MCP root (``.../mcp`` or ``.../mcp/``).
That root is rewritten to ``/mcp/x/all/readonly`` for sessions. For request headers,
we must not also send ``X-MCP-Toolsets`` with a subset, or the server narrows tools
below the read-only surface (e.g. omits ``search_repositories``).
"""
raw = url.strip()
if not raw:
return False
parsed = urlparse(raw)
host = parsed.netloc.lower()
if "@" in host:
host = host.split("@", 1)[-1]
if host != "api.githubcopilot.com":
return False
path_norm = (parsed.path or "/").rstrip("/").lower() or "/"
return path_norm == "/mcp"
def _remote_github_mcp_session_url(url: str) -> str:
"""Map generic Copilot MCP base URL to a path that exposes full read-only tools.
``https://api.githubcopilot.com/mcp/`` negotiates a *default* tool surface that is
smaller than the ``repos`` toolset (often omitting ``get_repository_tree``).
GitHub documents path-based selection: ``/mcp/x/all/readonly`` enables every
read-only tool. See github-mcp-server ``docs/remote-server.md``.
Custom paths (e.g. ``/mcp/x/repos``, ``/mcp/readonly``) are left unchanged.
"""
raw = url.strip()
if not raw:
return raw
if not _is_github_copilot_generic_mcp_root(url):
return raw
parsed = urlparse(raw)
new_path = "/mcp/x/all/readonly"
return urlunparse(
(parsed.scheme, parsed.netloc, new_path, parsed.params, parsed.query, parsed.fragment)
)
class GitHubMCPConfig(StrictConfigModel):
"""Normalized GitHub MCP connection settings."""
url: str = DEFAULT_GITHUB_MCP_URL
mode: Literal["stdio", "sse", "streamable-http"] = "streamable-http"
auth_token: str = ""
command: str = ""
args: tuple[str, ...] = ()
headers: dict[str, str] = Field(default_factory=dict)
toolsets: tuple[str, ...] = DEFAULT_GITHUB_MCP_TOOLSETS
timeout_seconds: float = Field(default=15.0, gt=0)
integration_id: str = ""
@field_validator("url", mode="before")
@classmethod
def _normalize_url(cls, value: Any) -> str:
normalized = str(value or DEFAULT_GITHUB_MCP_URL).strip()
return normalized or DEFAULT_GITHUB_MCP_URL
@field_validator("mode", mode="before")
@classmethod
def _normalize_mode(cls, value: Any) -> str:
normalized = str(value or DEFAULT_GITHUB_MCP_MODE).strip().lower()
return normalized or DEFAULT_GITHUB_MCP_MODE
@field_validator("args", mode="before")
@classmethod
def _normalize_args(cls, value: Any) -> tuple[str, ...]:
if value is None:
return ()
return tuple(str(arg).strip() for arg in value if str(arg).strip())
@field_validator("headers", mode="before")
@classmethod
def _normalize_headers(cls, value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {str(key): str(item).strip() for key, item in value.items() if str(item).strip()}
@field_validator("toolsets", mode="before")
@classmethod
def _normalize_toolsets(cls, value: Any) -> tuple[str, ...]:
if value is None:
return DEFAULT_GITHUB_MCP_TOOLSETS
toolsets = tuple(str(toolset).strip() for toolset in value if str(toolset).strip())
return toolsets or DEFAULT_GITHUB_MCP_TOOLSETS
@model_validator(mode="after")
def _validate_transport_requirements(self) -> GitHubMCPConfig:
if self.mode == "stdio" and not self.command:
raise ValueError("GitHub MCP mode 'stdio' requires a non-empty command.")
if self.mode != "stdio" and not self.url:
raise ValueError(f"GitHub MCP mode '{self.mode}' requires a non-empty url.")
return self
@property
def request_headers(self) -> dict[str, str]:
headers = {key: value for key, value in self.headers.items() if value}
if self.auth_token and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {self.auth_token}"
# Remote Copilot: explicit paths (e.g. ``/mcp/x/issues``) may need ``X-MCP-Toolsets``
# to merge toolsets. The generic ``/mcp`` root is rewritten to ``/mcp/x/all/readonly``
# for the session; do not send a subset via ``X-MCP-Toolsets`` there or tools like
# ``search_repositories`` disappear from the catalog.
if (
self.mode != "stdio"
and self.toolsets
and "X-MCP-Toolsets" not in headers
and not _is_github_copilot_generic_mcp_root(self.url)
):
headers["X-MCP-Toolsets"] = ",".join(self.toolsets)
return headers
@dataclass(frozen=True)
class GitHubMCPRepoProbeRow:
"""One repository row parsed from an MCP list/search payload (best-effort metadata)."""
full_name: str
private: bool | None
fork: bool | None
@dataclass(frozen=True)
class GitHubMCPValidationResult:
"""Result of validating a GitHub MCP connection."""
ok: bool
detail: str
tool_names: tuple[str, ...] = ()
authenticated_user: str = ""
failure_category: str = ""
repo_access_count: int | None = None
repo_access_scope_owners: tuple[str, ...] = ()
repo_access_samples: tuple[str, ...] = ()
repo_access_probe_tool: str = ""
repo_access_probe_rows: tuple[GitHubMCPRepoProbeRow, ...] = ()
repo_access_probe_limit_applied: int = 0
profile_public_repos: int | None = None
profile_private_repos: int | None = None
_FAILURE_TYPE_LABELS: dict[str, str] = {
"connectivity": "connectivity or transport (could not reach or initialize the MCP server)",
"authentication": "authentication (token missing, invalid, or rejected by GitHub)",
"insufficient_tools": "toolset (required MCP tools are not exposed; widen toolsets or server config)",
"repository_access": "repository access (repo listing failed or token lacks repo API access)",
"not_configured": "not configured (no auth token for the hosted GitHub Copilot MCP endpoint)",
}
def print_github_mcp_validation_report(
result: GitHubMCPValidationResult,
*,
console: Console | None = None,
detail_level: GitHubMcpDisplayDetailLevel = "standard",
) -> None:
"""Print validation outcome with Rich (tables / panels) for setup and wizard."""
if detail_level not in _GITHUB_MCP_DISPLAY_LEVELS:
detail_level = "standard"
out = console if console is not None else Console(highlight=False, soft_wrap=True)
if not result.ok:
body = format_github_mcp_validation_cli_report(result)
out.print(
Panel.fit(
body.strip(),
title=f"[bold {ERROR}]GitHub MCP · validation failed[/]",
border_style=ERROR,
)
)
return
who = (result.authenticated_user or "").strip()
identity = f"@{who}" if who else "(authenticated; login not in response)"
count = result.repo_access_count
count_str = "—" if count is None else str(count)
samples = list(result.repo_access_samples)
profile_pub = result.profile_public_repos
profile_priv = result.profile_private_repos
profile_counts_only = bool(count is not None and count > 0 and not samples)
summary = Table.grid(padding=(0, 2))
summary.add_column(style=DIM, justify="right")
summary.add_column()
summary.add_row("Status", f"[{HIGHLIGHT}]Configuration validation: succeeded[/]")
summary.add_row("GitHub identity", identity)
summary.add_row("Repositories returned (probe)", count_str)
if profile_pub is not None or profile_priv is not None:
pub_s = "—" if profile_pub is None else str(profile_pub)
priv_s = "—" if profile_priv is None else str(profile_priv)
summary.add_row("Profile totals", f"public {pub_s}, private {priv_s}")
if profile_counts_only:
summary.add_row(
"",
f"[{DIM}]Counts from GitHub profile (no sample repo names in this session).[/]",
)
blocks: list[Any] = [summary]
# "summary" is intentionally minimal and avoids printing repo names.
if detail_level in {"standard", "full"}:
scope = (
", ".join(result.repo_access_scope_owners) if result.repo_access_scope_owners else "—"
)
summary.add_row("Owners in scope", scope)
probe_tool = (result.repo_access_probe_tool or "").strip()
if detail_level in {"standard", "full"} and probe_tool:
summary.add_row(
"Access source",
f"[bold]{_probe_source_label(probe_tool)}[/]\n[{DIM}]{_probe_listing_caption(probe_tool)}[/]",
)
rows_detail = list(result.repo_access_probe_rows)
starred_col = _probe_starred_list_column(probe_tool)
# Only show repo names in the expanded view; "standard" stays clean.
if detail_level == "full" and (rows_detail or samples):
repo_table = Table(
title="[bold]Repositories[/]",
show_header=True,
header_style=f"bold {DIM}",
border_style=DIM,
)
repo_table.add_column("#", justify="right", style=DIM, width=4)
if rows_detail:
repo_table.add_column("Repository", style="default")
repo_table.add_column("Visibility", justify="center")
repo_table.add_column("Fork", justify="center")
repo_table.add_column("Starred (this list)", justify="center", style=DIM)
for i, row in enumerate(rows_detail, start=1):
repo_table.add_row(
str(i),
row.full_name,
_visibility_cell(row.private),
_fork_cell(row.fork),
starred_col,
)
else:
repo_table.add_column("owner/repo", style="default")
for i, name in enumerate(samples, start=1):
repo_table.add_row(str(i), name)
blocks.append(repo_table)
elif detail_level == "full" and not samples and not profile_counts_only:
blocks.append(f"[{DIM}]No repository names were returned by the listing probe.[/]")
panel_body: Any = Group(*blocks) if len(blocks) > 1 else blocks[0]
out.print(
Panel.fit(
panel_body,
title=f"[bold {BRAND}]GitHub MCP · connected[/]",
border_style=BRAND,
)
)
def format_github_mcp_validation_cli_report(result: GitHubMCPValidationResult) -> str:
"""Multi-line human report for verify output and non-rich contexts."""
if not result.ok:
category = (result.failure_category or "unknown").strip() or "unknown"
label = _FAILURE_TYPE_LABELS.get(category, category)
return "\n".join(
[
"Configuration validation: failed",
f"Failure type: {label}",
f"Details: {result.detail}",
]
)
who = (result.authenticated_user or "").strip()
identity_line = (
f"GitHub identity: @{who}"
if who
else "GitHub identity: (authenticated; login not in response)"
)
n = 0 if result.repo_access_count is None else result.repo_access_count
scope_txt = (
", ".join(result.repo_access_scope_owners)
if result.repo_access_scope_owners
else "none identified from listing"
)
samples_txt = (
", ".join(result.repo_access_samples)
if result.repo_access_samples
else "none in parsed listing"
)
lines = [
"Configuration validation: succeeded",
identity_line,
f"Repositories returned (probe): {n}",
f"Organization and user scope (owners in listing): {scope_txt}",
f"Representative repositories: {samples_txt}",
]
pub_n = result.profile_public_repos
priv_n = result.profile_private_repos
if pub_n is not None or priv_n is not None:
pub_s = "—" if pub_n is None else str(pub_n)
priv_s = "—" if priv_n is None else str(priv_n)
lines.append(f"Profile totals: public {pub_s}, private {priv_s}")
probe_tool = (result.repo_access_probe_tool or "").strip()
if probe_tool:
lines.append(
f"Repository access source: {_probe_source_label(probe_tool)} — "
f"{_probe_listing_caption(probe_tool)}"
)
agg = _repo_probe_aggregate_hint(result.repo_access_probe_rows)
if agg:
lines.append(f"Sample fields from API (partial): {agg}")
if result.repo_access_probe_limit_applied:
lines.append(
f"Stored up to {result.repo_access_probe_limit_applied} repos from this response "
"(set OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT to change the cap)."
)
return "\n".join(lines)
def build_github_mcp_config(raw: dict[str, Any] | None) -> GitHubMCPConfig:
"""Build a normalized config object from env/store data."""
data = dict(raw or {})
for key in _CREDENTIAL_METADATA_KEYS:
data.pop(key, None)
return GitHubMCPConfig.model_validate(data)
def github_mcp_config_from_env() -> GitHubMCPConfig | None:
"""Load a GitHub MCP config from env vars."""
mode = os.getenv("GITHUB_MCP_MODE", DEFAULT_GITHUB_MCP_MODE).strip().lower()
url = os.getenv("GITHUB_MCP_URL", "").strip()
command = os.getenv("GITHUB_MCP_COMMAND", "").strip()
auth_token = os.getenv("GITHUB_MCP_AUTH_TOKEN", "").strip()
toolsets_env = os.getenv("GITHUB_MCP_TOOLSETS", "").strip()
args_env = os.getenv("GITHUB_MCP_ARGS", "").strip()
if mode == "stdio":
if not command:
return None
elif not url:
return None
return build_github_mcp_config(
{
"url": url or DEFAULT_GITHUB_MCP_URL,
"mode": mode or DEFAULT_GITHUB_MCP_MODE,
"command": command,
"args": [part for part in args_env.split() if part],
"auth_token": auth_token,
"toolsets": [part.strip() for part in toolsets_env.split(",") if part.strip()],
}
)
def github_mcp_is_usably_configured(config: GitHubMCPConfig) -> bool:
"""Return True when credentials are enough to use GitHub MCP without re-prompting.
Aligns with validation's ``not_configured`` handling: stdio needs a command;
the hosted Copilot endpoint needs an auth token; other HTTP endpoints need a URL.
"""
if config.mode == "stdio":
return bool(config.command.strip())
if config.auth_token.strip():
return True
if _is_github_copilot_host(config.url):
return False
return bool(config.url.strip())
def github_integration_is_configured() -> bool:
"""Return True when GitHub MCP is configured with usable store or env credentials."""
from integrations.store import get_integration
record = get_integration("github")
if record is not None:
credentials = record.get("credentials") or {}
try:
if github_mcp_is_usably_configured(build_github_mcp_config(credentials)):
return True
except Exception:
logger.warning(
"Ignoring invalid GitHub integration credentials in store",
exc_info=True,
)
try:
env_config = github_mcp_config_from_env()
except Exception:
logger.warning("Ignoring invalid GitHub MCP env configuration", exc_info=True)
return False
return env_config is not None and github_mcp_is_usably_configured(env_config)
@asynccontextmanager
async def _open_github_mcp_session(config: GitHubMCPConfig) -> AsyncIterator[ClientSession]:
stack = AsyncExitStack()
try:
if config.mode == "stdio":
if not config.command:
raise ValueError(
"Invalid GitHub MCP config: mode=stdio requires command "
"(set OPENSRE_GITHUB_MCP_COMMAND or pass command "
"in config)."
)
server_params = StdioServerParameters(
command=config.command,
args=list(config.args),
env={
**os.environ,
# Suppress terminal control codes (cursor, color) so the MCP
# server's stdout stays clean JSON-RPC. Some server binaries
# output ANSI escape sequences when TERM is set, breaking
# the stdio reader with a JSONRPCMessage parse error.
"NO_COLOR": "1",
"TERM": "dumb",
**(
{"GITHUB_PERSONAL_ACCESS_TOKEN": config.auth_token}
if config.auth_token
else {}
),
},
)
read_stream, write_stream = await stack.enter_async_context(stdio_client(server_params))
elif config.mode == "sse":
if not config.url:
raise ValueError(
"Invalid GitHub MCP config: mode=sse requires url "
"(set OPENSRE_GITHUB_MCP_URL, "
"for example https://.../sse)."
)
session_url = _remote_github_mcp_session_url(config.url)
read_stream, write_stream = await stack.enter_async_context(
sse_client(
session_url,
headers=config.request_headers,
timeout=config.timeout_seconds,
sse_read_timeout=max(60.0, config.timeout_seconds),
)
)
elif config.mode == "streamable-http":
if not config.url:
raise ValueError(
"Invalid GitHub MCP config: "
"mode=streamable-http requires url "
"(set OPENSRE_GITHUB_MCP_URL)."
)
session_url = _remote_github_mcp_session_url(config.url)
read_timeout = max(60.0, config.timeout_seconds)
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers=config.request_headers,
timeout=httpx.Timeout(config.timeout_seconds, read=read_timeout),
)
)
read_stream, write_stream, _ = await stack.enter_async_context(
streamable_http_client(
session_url,
http_client=http_client,
headers=config.request_headers,
timeout=config.timeout_seconds,
sse_read_timeout=read_timeout,
)
)
else:
raise ValueError(
f"Unsupported GitHub MCP mode '{config.mode}'. "
"Supported modes: stdio, sse, streamable-http."
)
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
yield session
finally:
await stack.aclose()
def _run_async(coro: Any) -> Any:
try:
return asyncio.run(coro)
except BaseException:
close = getattr(coro, "close", None)
if callable(close):
close()
raise
def _root_cause_message(exc: BaseException) -> str:
"""Best-effort unwrap for ExceptionGroup/TaskGroup."""
if isinstance(exc, ExceptionGroup) and exc.exceptions:
return _root_cause_message(exc.exceptions[0])
cause = getattr(exc, "__cause__", None)
if isinstance(cause, BaseException):
return _root_cause_message(cause)
context = getattr(exc, "__context__", None)
if isinstance(context, BaseException):
return _root_cause_message(context)
return f"{exc.__class__.__name__}: {exc}"
def _connectivity_failure_detail(err: BaseException) -> str:
msg = _root_cause_message(err)
return "\n".join(
[
msg,
"",
"Check: outbound HTTPS",
"- to api.githubcopilot.com",
"- token validity / GitHub auth session",
"- toolsets and MCP base URL path",
]
).strip()
def _tool_result_to_dict(result: types.CallToolResult) -> dict[str, Any]:
text_parts: list[str] = []
content_items: list[dict[str, Any]] = []
for item in result.content:
if isinstance(item, types.TextContent):
text_parts.append(item.text)
content_items.append({"type": "text", "text": item.text})
elif isinstance(item, types.EmbeddedResource):
resource = item.resource
if isinstance(resource, types.TextResourceContents):
content_items.append(
{
"type": "resource_text",
"uri": str(resource.uri),
"text": resource.text,
}
)
text_parts.append(resource.text)
elif isinstance(resource, types.BlobResourceContents):
content_items.append(
{
"type": "resource_blob",
"uri": str(resource.uri),
"mime_type": resource.mimeType,
}
)
else:
content_items.append({"type": getattr(item, "type", "unknown")})
structured = getattr(result, "structuredContent", None)
text_output = "\n".join(part.strip() for part in text_parts if part.strip()).strip()
return {
"is_error": bool(result.isError),
"text": text_output,
"content": content_items,
"structured_content": structured,
}
async def _list_tools_async(config: GitHubMCPConfig) -> list[types.Tool]:
async with _open_github_mcp_session(config) as session:
result = await session.list_tools()
return list(result.tools)
def list_github_mcp_tools(config: GitHubMCPConfig) -> list[dict[str, Any]]:
"""List available tools from a GitHub MCP server."""
tools = _run_async(_list_tools_async(config))
return [
{
"name": tool.name,
"description": tool.description or "",
"input_schema": getattr(tool, "inputSchema", None),
}
for tool in tools
]
async def _call_tool_async(
config: GitHubMCPConfig,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with _open_github_mcp_session(config) as session:
result = await session.call_tool(tool_name, arguments or {})
payload = _tool_result_to_dict(result)
payload["tool"] = tool_name
payload["arguments"] = arguments or {}
return payload
def call_github_mcp_tool(
config: GitHubMCPConfig,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call a GitHub MCP tool and normalize the result."""
try:
return cast(dict[str, Any], _run_async(_call_tool_async(config, tool_name, arguments)))
except Exception as err:
logger.debug("GitHub MCP tool call failed: %s", tool_name, exc_info=True)
return {
"is_error": True,
"text": _root_cause_message(err),
"content": [],
"structured_content": None,
"tool": tool_name,
"arguments": arguments or {},
}
def _json_schema_allows_empty_object_call(schema: Any) -> bool:
"""True if the tool can be invoked with {} (no required properties)."""
if schema is None:
return True
if not isinstance(schema, dict):
return False
if schema.get("allOf") or schema.get("anyOf") or schema.get("oneOf"):
return False
required = schema.get("required")
return not (isinstance(required, list) and len(required) > 0)
def _owners_from_repo_full_names(names: Sequence[str]) -> tuple[str, ...]:
owners: list[str] = []
seen: set[str] = set()
for full in names:
if "/" not in full:
continue
owner = full.split("/", 1)[0].strip()
if owner and owner not in seen:
seen.add(owner)
owners.append(owner)
return tuple(sorted(owners, key=str.lower))
def _repo_probe_capture_limit() -> int:
raw = os.getenv("OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT", "").strip()
if not raw:
return _DEFAULT_REPO_PROBE_LIMIT
try:
return max(5, min(int(raw), 500))
except ValueError:
return _DEFAULT_REPO_PROBE_LIMIT
def _probe_listing_caption(tool_name: str) -> str:
"""Explain what the probe list represents (depends on which MCP tool ran)."""
t = (tool_name or "").strip().lower()
if t == "list_starred_repositories":
return "Repositories you have starred (not necessarily owned by you)."
if t == "list_user_repositories":
return "Repositories for your user account as defined by the GitHub MCP tool."
if t == "list_repositories":
return "Repositories returned for this token (tool-defined scope)."
if t == "search_repositories":
return "Repositories matching the probe search query (e.g. user:yourlogin)."
return "Repositories returned by the MCP probe."
def _probe_source_label(tool_name: str) -> str:
"""Human-friendly label for which repo list we displayed."""
t = (tool_name or "").strip().lower()
if t == "list_starred_repositories":
return "Starred repositories"
if t == "list_user_repositories":
return "User repositories"
if t == "list_repositories":
return "Accessible repositories"
if t == "search_repositories":
return "Repository search results"
return "Repository listing"
def _probe_starred_list_column(tool_name: str) -> str:
"""Whether every row in this listing is a starred repo (by probe semantics)."""
t = (tool_name or "").strip().lower()
if t == "list_starred_repositories":
return "yes"
return "—"
def _visibility_cell(private: bool | None) -> str:
if private is True:
return "private"
if private is False:
return "public"
return "unknown"
def _fork_cell(fork: bool | None) -> str:
if fork is True:
return "yes"
if fork is False:
return "no"
return "unknown"
def _repo_visibility_counts_from_get_me_profile(
structured: dict[str, Any],
text: str,
) -> tuple[int | None, int | None]:
def extract(obj: dict[str, Any]) -> tuple[int | None, int | None]:
details = obj.get("details") or obj.get("Details")
if not isinstance(details, dict):
return None, None
pub_raw = details.get("public_repos")
if pub_raw is None:
pub_raw = details.get("publicRepos")
priv_raw = details.get("total_private_repos")
if priv_raw is None:
priv_raw = details.get("totalPrivateRepos")
try:
public_n = int(pub_raw) if pub_raw is not None else None
except (TypeError, ValueError):
public_n = None
try:
private_n = int(priv_raw) if priv_raw is not None else None
except (TypeError, ValueError):
private_n = None
return public_n, private_n
got = extract(structured)
if got != (None, None):
return got
try:
payload = json.loads(text or "{}")
except json.JSONDecodeError:
return None, None
return extract(payload) if isinstance(payload, dict) else (None, None)
def _repo_counts_from_get_me_profile(structured: dict[str, Any], text: str) -> int | None:
"""Best-effort total repo count from get_me (public + private) when listing tools are absent."""
pub, priv = _repo_visibility_counts_from_get_me_profile(structured, text)
if pub is None and priv is None:
return None
return int(pub or 0) + int(priv or 0)
def _github_mcp_verify_orgs_from_env() -> tuple[str, ...]:
"""Optional org logins to probe via ``search_repositories org:<login>`` (comma-separated env)."""
raw = os.getenv("OPENSRE_GITHUB_MCP_VERIFY_ORGS", "").strip()
if not raw:
return ()
orgs: list[str] = []
seen: set[str] = set()
for part in raw.split(","):
org = part.strip()
if org and org.lower() not in seen:
seen.add(org.lower())
orgs.append(org)
return tuple(orgs)
def _is_recoverable_repo_probe_error(
tool_name: str,
_args: dict[str, Any],
detail: str,
) -> bool:
"""True when a failed repo probe should try another query or softer fallback.
Hosted MCP often validates via ``search_repositories user:<login>``. That query
fails with HTTP 422 when the user has no personally-owned repos (common for
org-centric accounts) even though org repo access is fine — those failures are
recoverable. Hard permission errors from listing tools (e.g. 403) are not.
"""
if tool_name == "search_repositories":
lowered = detail.lower()
return "422" in lowered or "validation failed" in lowered or "cannot be searched" in lowered
return False
def _validation_result_from_get_me_profile_counts(
*,
user_name: str,
tool_names: tuple[str, ...],
structured: dict[str, Any],
me_text: str,
profile_pub: int | None,
profile_priv: int | None,
note: str,
) -> GitHubMCPValidationResult | None:
profile_count = _repo_counts_from_get_me_profile(structured, me_text)
if profile_count is None:
return None
scope_me = (user_name,) if user_name else ()
success_detail = (
f"OK @{user_name or 'unknown'}; repos={profile_count}; "
f"owners={','.join(scope_me) if scope_me else '-'}; "
f"examples=-; mcp_tools={len(tool_names)} | {note}"
)
return GitHubMCPValidationResult(
ok=True,
detail=success_detail,
tool_names=tool_names,
authenticated_user=user_name,
repo_access_count=profile_count,
repo_access_scope_owners=scope_me,
repo_access_samples=(),
profile_public_repos=profile_pub,
profile_private_repos=profile_priv,
)
def _validation_result_authenticated_without_repo_samples(
*,
user_name: str,
tool_names: tuple[str, ...],
profile_pub: int | None,
profile_priv: int | None,
note: str,
) -> GitHubMCPValidationResult:
who = user_name or "unknown"
success_detail = (
f"OK @{who}; repos=-; owners={who if user_name else '-'}; examples=-; "
f"mcp_tools={len(tool_names)} | {note}"
)
scope_me = (user_name,) if user_name else ()
return GitHubMCPValidationResult(
ok=True,
detail=success_detail,
tool_names=tool_names,
authenticated_user=user_name,
repo_access_count=None,
repo_access_scope_owners=scope_me,
repo_access_samples=(),
profile_public_repos=profile_pub,
profile_private_repos=profile_priv,
)
def _validation_success_from_repo_probe_result(
*,
user_name: str,
tool_names: tuple[str, ...],
repo_tool: str,
list_result: dict[str, Any],
repo_visibility: GitHubMcpRepoVisibilityFilter,
profile_pub: int | None,
profile_priv: int | None,
) -> GitHubMCPValidationResult:
limit = _repo_probe_capture_limit()
rows_all = _repo_probe_rows_from_tool_result(list_result)
rows_filtered = _filter_repo_rows(rows_all, visibility=repo_visibility)
all_names = [r.full_name for r in rows_filtered]
repo_count = len(all_names)
samples_rows = tuple(rows_filtered[:limit])
samples = tuple(r.full_name for r in samples_rows)
scope = _owners_from_repo_full_names(all_names)
suffix = ""
if not all_names:
suffix = (
f" | listing had no parseable repos ({repo_tool} empty or unexpected response shape)"
)
success_detail = (
f"OK @{user_name or 'unknown'}; repos={repo_count}; owners={','.join(scope) if scope else '-'}; "
f"examples={','.join(samples[:3]) if samples else '-'}; mcp_tools={len(tool_names)}"
f"{suffix}"
)
return GitHubMCPValidationResult(
ok=True,
detail=success_detail,
tool_names=tool_names,
authenticated_user=user_name,
repo_access_count=repo_count,
repo_access_scope_owners=scope,
repo_access_samples=samples,
repo_access_probe_tool=repo_tool,
repo_access_probe_rows=samples_rows,
repo_access_probe_limit_applied=limit,
profile_public_repos=profile_pub,
profile_private_repos=profile_priv,
)
def _repo_probe_aggregate_hint(rows: Sequence[GitHubMCPRepoProbeRow]) -> str | None:
if not rows:
return None
public_n = sum(1 for r in rows if r.private is False)
private_n = sum(1 for r in rows if r.private is True)
unknown_vis = sum(1 for r in rows if r.private is None)
fork_n = sum(1 for r in rows if r.fork is True)
parts: list[str] = []
if public_n or private_n or unknown_vis:
parts.append(
f"visibility in sample: public {public_n}, private {private_n}, unknown {unknown_vis}"
)
if fork_n:
parts.append(f"forks in sample: {fork_n}")
return "; ".join(parts) if parts else None
def _filter_repo_rows(
rows: Sequence[GitHubMCPRepoProbeRow],
*,
visibility: GitHubMcpRepoVisibilityFilter = "any",
) -> list[GitHubMCPRepoProbeRow]:
if visibility == "any":
return list(rows)
want_private = visibility == "private"
return [r for r in rows if r.private is not None and r.private is want_private]
def _repo_dict_to_row(node: dict[str, Any]) -> GitHubMCPRepoProbeRow | None:
fn = node.get("full_name") or node.get("fullName")
full: str | None = None
if isinstance(fn, str) and fn.strip():
full = fn.strip()
else:
owner = node.get("owner")
name = node.get("name")
if isinstance(owner, dict) and isinstance(name, str):
login = owner.get("login")
if isinstance(login, str) and login.strip():
full = f"{login.strip()}/{name.strip()}"
if not full:
return None
priv_raw = node.get("private")
vis = node.get("visibility")
private: bool | None
if isinstance(priv_raw, bool):
private = priv_raw
elif isinstance(vis, str):
v = vis.lower()
private = True if v == "private" else False if v == "public" else None
else:
private = None
frk = node.get("fork")
fork = frk if isinstance(frk, bool) else None
return GitHubMCPRepoProbeRow(full_name=full, private=private, fork=fork)
def _collect_repo_probe_rows_from_payload(data: Any) -> list[GitHubMCPRepoProbeRow]:
found: list[GitHubMCPRepoProbeRow] = []
seen: set[str] = set()
def walk(node: Any) -> None:
if isinstance(node, list):
for item in node:
walk(item)
return
if isinstance(node, dict):
row = _repo_dict_to_row(node)
if row and row.full_name not in seen:
seen.add(row.full_name)
found.append(row)
for _key, val in node.items():
if isinstance(val, (list, dict)):
walk(val)
walk(data)
return found
def _repo_probe_rows_from_tool_result(result: dict[str, Any]) -> list[GitHubMCPRepoProbeRow]:
structured = result.get("structured_content")
if structured is not None:
rows = _collect_repo_probe_rows_from_payload(structured)
if rows:
return rows
text = str(result.get("text") or "").strip()
if not text:
return []
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return []
return _collect_repo_probe_rows_from_payload(parsed)
def _repo_probe_order_and_search_fallback(
view: GitHubMcpRepoView,
) -> tuple[tuple[str, ...], bool]:
view_norm = view
if view_norm == "starred":
return ("list_starred_repositories",), False
if view_norm == "user":
return ("list_user_repositories",), True
if view_norm == "accessible":
return ("list_repositories",), False
if view_norm == "search_user":
return (), True
return _REPO_PROBE_NO_ARG_TOOLS, True
def _iter_repo_access_probe_plans(
tools: list[dict[str, Any]],
authenticated_login: str,
*,
view: GitHubMcpRepoView = "auto",
) -> tuple[tuple[str, dict[str, Any]], ...]:
"""Ordered repo-access probes: list tools, user search, then optional org searches."""
by_name = {str(t["name"]): t for t in tools if t.get("name")}
ordered, allow_search_fallback = _repo_probe_order_and_search_fallback(view)
plans: list[tuple[str, dict[str, Any]]] = []
for name in ordered:
entry = by_name.get(name)
if not entry:
continue
if _json_schema_allows_empty_object_call(entry.get("input_schema")):
plans.append((name, {}))
login = (authenticated_login or "").strip()
if allow_search_fallback and login and "search_repositories" in by_name:
plans.append(("search_repositories", {"query": f"user:{login}"}))
if "search_repositories" in by_name:
for org in _github_mcp_verify_orgs_from_env():
org_plan = ("search_repositories", {"query": f"org:{org}"})
if org_plan not in plans:
plans.append(org_plan)
return tuple(plans)
def _repo_probe_attempts(
tools: list[dict[str, Any]],
authenticated_login: str,
*,
view: GitHubMcpRepoView = "auto",
) -> tuple[str, ...]:
"""Human-readable probe names for errors (includes tools that need arguments)."""
by_name = {str(t["name"]): t for t in tools if t.get("name")}
ordered, allow_search_fallback = _repo_probe_order_and_search_fallback(view)
attempts = [name for name in ordered if name in by_name]
login = (authenticated_login or "").strip()
if (
allow_search_fallback
and login
and "search_repositories" in by_name
and "search_repositories" not in attempts
):
attempts.append("search_repositories")
if (
_github_mcp_verify_orgs_from_env()
and "search_repositories" in by_name
and "search_repositories" not in attempts
):
attempts.append("search_repositories")
return tuple(attempts)
def _format_repo_probe_attempts(attempts: Sequence[str]) -> str:
if not attempts:
return "none"
if len(attempts) == 1:
return attempts[0]
return f"{', '.join(attempts[:-1])}, then {attempts[-1]}"
def _plan_repo_access_probe(
tools: list[dict[str, Any]],
authenticated_login: str,
*,
view: GitHubMcpRepoView = "auto",
) -> tuple[str, dict[str, Any]] | None:
"""Pick the first repo-access probe (hosted MCP shapes differ from local)."""
plans = _iter_repo_access_probe_plans(tools, authenticated_login, view=view)
return plans[0] if plans else None
def _repo_access_probe_fallback_result(
*,
user_name: str,
tool_names: tuple[str, ...],
structured: dict[str, Any],
me_text: str,
profile_pub: int | None,
profile_priv: int | None,
last_probe_tool: str,
last_probe_detail: str,
) -> GitHubMCPValidationResult:
"""Softer validation when brittle search probes fail but auth succeeded."""
profile_result = _validation_result_from_get_me_profile_counts(
user_name=user_name,
tool_names=tool_names,
structured=structured,
me_text=me_text,
profile_pub=profile_pub,
profile_priv=profile_priv,
note=(
"repository counts from get_me profile "
f"(repo probe {last_probe_tool} failed: {last_probe_detail.strip()})"
),
)
if profile_result is not None:
return profile_result
return _validation_result_authenticated_without_repo_samples(
user_name=user_name,
tool_names=tool_names,
profile_pub=profile_pub,
profile_priv=profile_priv,
note=(
"authenticated; repo probes inconclusive "
f"({last_probe_tool}: {last_probe_detail.strip()}); "
"MCP investigation tools are available"
),
)
def validate_github_mcp_config(
config: GitHubMCPConfig,
*,
repo_view: GitHubMcpRepoView = "auto",
repo_visibility: GitHubMcpRepoVisibilityFilter = "any",
) -> GitHubMCPValidationResult:
"""Validate connectivity, authentication, and repo-access readiness."""
# A record that targets the hosted Copilot endpoint with no token (e.g. a
# stale store entry from an abandoned setup) cannot authenticate. Report it
# as "not configured" instead of probing the public endpoint and surfacing
# a confusing 401 (and without emitting a Sentry validation-failed event).
if config.mode != "stdio" and not config.auth_token and _is_github_copilot_host(config.url):
return GitHubMCPValidationResult(
ok=False,
detail=(
"GitHub MCP is configured without an auth token, so the hosted "
"GitHub Copilot MCP endpoint cannot be reached (it requires "
"authentication). Run `opensre integrations setup` to add a token, "
"set GITHUB_MCP_AUTH_TOKEN, or remove it with "
"`opensre integrations remove github`."
),
failure_category="not_configured",
)
try:
tools = list_github_mcp_tools(config)
tool_names = tuple(sorted(tool["name"] for tool in tools))
missing = sorted(set(REQUIRED_SOURCE_INVESTIGATION_TOOLS) - set(tool_names))
if missing:
return GitHubMCPValidationResult(
ok=False,
detail=(
"GitHub MCP connected, but required repository investigation tools are missing: "
f"{', '.join(missing)}."
),
tool_names=tool_names,
failure_category="insufficient_tools",
)
if "get_me" not in tool_names:
return GitHubMCPValidationResult(
ok=False,
detail=(
"GitHub MCP connected, but the required identity tool 'get_me' is not exposed. "
"Widen your toolsets to include it."
),
tool_names=tool_names,
failure_category="insufficient_tools",
)
me_result = call_github_mcp_tool(config, "get_me", {})
if me_result.get("is_error"):
detail = me_result.get("text") or "Unknown authentication failure."
return GitHubMCPValidationResult(
ok=False,
detail=f"GitHub MCP connected, but authentication failed: {detail}",
tool_names=tool_names,
failure_category="authentication",
)
structured: dict[str, Any] = {}
raw_structured = me_result.get("structured_content")
if isinstance(raw_structured, dict):
structured = raw_structured
profile_pub, profile_priv = _repo_visibility_counts_from_get_me_profile(
structured, me_result.get("text", "")
)
user_name = str(structured.get("login") or structured.get("name") or "").strip()
if not user_name:
try:
payload = json.loads(me_result.get("text", "{}"))
user_name = str(payload.get("login") or payload.get("name") or "").strip()
except json.JSONDecodeError:
user_name = ""
who = user_name or "authenticated GitHub user"
probe_plans = _iter_repo_access_probe_plans(tools, user_name, view=repo_view)
if not probe_plans:
attempted_tools = _repo_probe_attempts(tools, user_name, view=repo_view)
profile_result = _validation_result_from_get_me_profile_counts(
user_name=user_name,
tool_names=tool_names,
structured=structured,
me_text=me_result.get("text", ""),
profile_pub=profile_pub,
profile_priv=profile_priv,
note=("repository counts from get_me profile (no list/search repo tool exposed)"),
)
if profile_result is not None:
return profile_result
return GitHubMCPValidationResult(
ok=False,
detail=(
f"Authenticated as {who}, but no repository listing or search tool was usable "
f"(tried: {_format_repo_probe_attempts(attempted_tools)}). "
"Enable toolsets that include repo listing or search (e.g. add `search` or "
"`stargazers` alongside repos) for api.githubcopilot.com."
),
tool_names=tool_names,
authenticated_user=user_name,
failure_category="repository_access",
profile_public_repos=profile_pub,
profile_private_repos=profile_priv,
)
last_probe_tool = ""
last_probe_detail = ""
for repo_tool, repo_args in probe_plans:
list_result = call_github_mcp_tool(config, repo_tool, repo_args)
if not list_result.get("is_error"):
return _validation_success_from_repo_probe_result(
user_name=user_name,
tool_names=tool_names,
repo_tool=repo_tool,
list_result=list_result,
repo_visibility=repo_visibility,
profile_pub=profile_pub,
profile_priv=profile_priv,
)
last_probe_tool = repo_tool
last_probe_detail = str(
list_result.get("text") or "Unknown error listing repositories."
)
if not _is_recoverable_repo_probe_error(repo_tool, repo_args, last_probe_detail):
return GitHubMCPValidationResult(
ok=False,
detail=(
f"Authenticated as {who}, but repository access check failed ({repo_tool}): "
f"{last_probe_detail} "
"(connectivity OK; auth or token scope may be insufficient for repo APIs)."
),
tool_names=tool_names,
authenticated_user=user_name,
failure_category="repository_access",
profile_public_repos=profile_pub,
profile_private_repos=profile_priv,
)
return _repo_access_probe_fallback_result(
user_name=user_name,
tool_names=tool_names,
structured=structured,
me_text=me_result.get("text", ""),
profile_pub=profile_pub,
profile_priv=profile_priv,
last_probe_tool=last_probe_tool,
last_probe_detail=last_probe_detail,
)
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="github_mcp",
method="validate_github_mcp_config",
)
return GitHubMCPValidationResult(
ok=False,
detail=_connectivity_failure_detail(err),
failure_category="connectivity",
)
def build_github_code_search_query(owner: str, repo: str, query: str) -> str:
"""Build a repo-scoped GitHub code search query."""
repo_qualifier = f"repo:{owner}/{repo}"
query = query.strip()
if repo_qualifier in query:
return query
return f"{query} {repo_qualifier}".strip()
def build_github_issue_search_query(owner: str, repo: str, query: str, state: str = "open") -> str:
"""Build a repo-scoped GitHub issue search query."""
query = query.strip()
parts: list[str] = []
repo_qualifier = f"repo:{owner}/{repo}"
if repo_qualifier not in query:
parts.append(repo_qualifier)
if "is:issue" not in query:
parts.append("is:issue")
normalized_state = state.strip().lower()
if normalized_state in {"open", "closed"} and f"is:{normalized_state}" not in query:
parts.append(f"is:{normalized_state}")
if query:
parts.append(query)
return " ".join(parts).strip()
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[GitHubMCPConfig | None, str | None]:
try:
cfg = build_github_mcp_config(
{
"url": credentials.get("url", ""),
"mode": credentials.get("mode", "streamable-http"),
"command": credentials.get("command", ""),
"args": credentials.get("args", []),
"auth_token": credentials.get("auth_token", ""),
"toolsets": credentials.get("toolsets", []),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="github", record_id=record_id)
return None, None
return cfg, "github"