From 1fd839e971537c2d8b9712170c29332ca807f7c7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:20:34 +0000 Subject: [PATCH 1/3] feat(mcp): add get_github_issue_creation_link tool Co-Authored-By: AJ Steers --- airbyte/mcp/guidance.py | 57 ++++++++++++++++++- tests/unit_tests/fixtures/arg_trace_plan.json | 5 ++ tests/unit_tests/test_mcp_guidance.py | 43 ++++++++++++++ tests/unit_tests/test_mcp_tool_annotations.py | 13 ++++- 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/guidance.py b/airbyte/mcp/guidance.py index dbda460f3..712df2296 100644 --- a/airbyte/mcp/guidance.py +++ b/airbyte/mcp/guidance.py @@ -11,7 +11,9 @@ __all__: list[str] = [] import contextlib -from typing import TYPE_CHECKING, Annotated, Any, Literal +from enum import StrEnum +from typing import TYPE_CHECKING, Annotated, Any, Literal, cast +from urllib.parse import quote, urlencode import requests from fastmcp import Context, FastMCP @@ -56,6 +58,15 @@ _KAPA_TIMEOUT_SECONDS = 30.0 KNOWLEDGE_SEARCH_CAPABILITY = "io.airbyte/knowledge-search" +GITHUB_ISSUES_REPO = "airbytehq/PyAirbyte" + + +class GitHubIssueCategory(StrEnum): + """Issue categories, each matching an issue form in `.github/ISSUE_TEMPLATE/`.""" + + CLOUD_MCP_BUG_REPORT = "cloud-mcp-bug-report" + CLOUD_MCP_FEATURE_REQUEST = "cloud-mcp-feature-request" + KAPA_API_KEY_CONFIG_ARG = MCPServerConfigArg( name="kapa_api_key", @@ -333,6 +344,50 @@ def get_api_docs_urls( return "Connector not found." +@mcp_tool(read_only=True, idempotent=True) +def get_github_issue_creation_link( + category: Annotated[ + GitHubIssueCategory, + Field(description="The GitHub issue form to use for the Airbyte Cloud MCP server."), + TraceArg.VALUE, + ], + title: Annotated[ + str, + Field(description="A short, specific issue title.", min_length=1, max_length=200), + TraceArg.FINGERPRINT, + ], + description: Annotated[ + str, + Field( + description=( + "A Markdown description of what happened, what was expected, " + "and the tool calls involved." + ), + min_length=1, + max_length=4000, + ), + TraceArg.FINGERPRINT, + ], +) -> str: + """Create a link to a pre-filled public GitHub issue for the Airbyte Cloud MCP server. + + Use this when a user encounters a bug in this server's tools or asks for a capability it + lacks. Always give the returned URL to the user as a clickable link. This tool does not file + anything: the user must be signed in to GitHub to review and submit the issue under their own + account. Issues are public, so never include secrets, credentials, connector config values, or + customer data. Draft the description in Markdown with what happened, what was expected, and + the tool calls involved. + """ + return f"https://github.com/{GITHUB_ISSUES_REPO}/issues/new?" + urlencode( + { + "template": f"{category.value}.yml", + "title": title, + "description": description, + }, + quote_via=cast(Any, quote), + ) + + def register_guidance_tools(app: FastMCP) -> None: """Register guidance tools and prompts with the FastMCP app.""" register_mcp_tools(app, mcp_module=__name__) diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 90abfd011..1ea9b8bf7 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -138,6 +138,11 @@ "workspace_id": "omit" }, "get_default_cloud_context": {}, + "get_github_issue_creation_link": { + "category": "value allowed=[\"cloud-mcp-bug-report\",\"cloud-mcp-feature-request\"]", + "description": "fingerprint", + "title": "fingerprint" + }, "get_source_stream_json_schema": { "config": "presence", "config_file": "fingerprint", diff --git a/tests/unit_tests/test_mcp_guidance.py b/tests/unit_tests/test_mcp_guidance.py index 7d0e875a9..c2da859b9 100644 --- a/tests/unit_tests/test_mcp_guidance.py +++ b/tests/unit_tests/test_mcp_guidance.py @@ -5,6 +5,7 @@ import asyncio import json from typing import cast +from urllib.parse import parse_qs, urlsplit import pytest import requests @@ -47,6 +48,48 @@ def _airbyte_mcp_app() -> FastMCP: return app +@pytest.mark.parametrize( + ("category", "slug"), + [ + pytest.param( + guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, + "cloud-mcp-bug-report", + id="bug-report", + ), + pytest.param( + guidance.GitHubIssueCategory.CLOUD_MCP_FEATURE_REQUEST, + "cloud-mcp-feature-request", + id="feature-request", + ), + ], +) +def test_get_github_issue_creation_link( + category: guidance.GitHubIssueCategory, slug: str +) -> None: + title = "Bug & regression #42 + edge case? café" + description = ( + "What happened: the `tool+name` call failed & returned #1? Unexpectedly.\n" + "Expected: success.\nTool calls: `get_cloud_sync_status`." + ) + + parsed_url = urlsplit( + guidance.get_github_issue_creation_link(category, title, description) + ) + query = parse_qs(parsed_url.query) + + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "github.com" + assert parsed_url.path == "/airbytehq/PyAirbyte/issues/new" + assert set(query) == {"template", "title", "description"} + assert query["template"] == [f"{slug}.yml"] + assert query["title"] == [title] + assert query["description"] == [description] + + +def test_get_github_issue_creation_link_is_listed_by_app() -> None: + assert "get_github_issue_creation_link" in _list_tool_names(_airbyte_mcp_app()) + + @pytest.mark.parametrize( ("hosted", "api_key", "retrieval_api_url", "expected_visible"), [ diff --git a/tests/unit_tests/test_mcp_tool_annotations.py b/tests/unit_tests/test_mcp_tool_annotations.py index aee3c206a..0b1aa30d2 100644 --- a/tests/unit_tests/test_mcp_tool_annotations.py +++ b/tests/unit_tests/test_mcp_tool_annotations.py @@ -4,7 +4,7 @@ from __future__ import annotations import airbyte.mcp.server # noqa: F401 # Importing registers every MCP tool module. -from airbyte.mcp import cloud as cloud_mcp +from airbyte.mcp import cloud as cloud_mcp, guidance as guidance_mcp from fastmcp_extensions.decorators import _REGISTERED_TOOLS # noqa: PLC2701 READ_ONLY_NAME_PREFIXES = ("list_", "describe_", "get_", "check_") @@ -27,6 +27,17 @@ def test_check_cloud_connector_is_read_only_and_idempotent() -> None: assert annotations["idempotentHint"] is True +def test_get_github_issue_creation_link_is_read_only_and_idempotent() -> None: + """`get_github_issue_creation_link` only returns a GitHub URL.""" + (annotations,) = [ + a + for f, a in _REGISTERED_TOOLS + if f is guidance_mcp.get_github_issue_creation_link + ] + assert annotations["readOnlyHint"] is True + assert annotations["idempotentHint"] is True + + def test_read_prefixed_cloud_tools_are_all_read_only() -> None: """Every cloud tool named list_*/describe_*/get_*/check_* must be read-only.""" violations = [ From 2580985ff6f03238bdcd8dd339fa6f7495056331 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:27:33 +0000 Subject: [PATCH 2/3] fix(mcp): cap encoded GitHub issue link length Co-Authored-By: AJ Steers --- airbyte/mcp/guidance.py | 15 ++++++++++++++- tests/unit_tests/test_mcp_guidance.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/guidance.py b/airbyte/mcp/guidance.py index 712df2296..0984526d9 100644 --- a/airbyte/mcp/guidance.py +++ b/airbyte/mcp/guidance.py @@ -59,6 +59,7 @@ KNOWLEDGE_SEARCH_CAPABILITY = "io.airbyte/knowledge-search" GITHUB_ISSUES_REPO = "airbytehq/PyAirbyte" +MAX_GITHUB_ISSUE_URL_LENGTH = 8000 class GitHubIssueCategory(StrEnum): @@ -378,7 +379,7 @@ def get_github_issue_creation_link( customer data. Draft the description in Markdown with what happened, what was expected, and the tool calls involved. """ - return f"https://github.com/{GITHUB_ISSUES_REPO}/issues/new?" + urlencode( + url = f"https://github.com/{GITHUB_ISSUES_REPO}/issues/new?" + urlencode( { "template": f"{category.value}.yml", "title": title, @@ -386,6 +387,18 @@ def get_github_issue_creation_link( }, quote_via=cast(Any, quote), ) + if len(url) > MAX_GITHUB_ISSUE_URL_LENGTH: + raise exc.AirbyteLibInputError( + message=( + "The issue link is too long after URL encoding. " + "Shorten the title or description and try again." + ), + context={ + "url_length": len(url), + "max_url_length": MAX_GITHUB_ISSUE_URL_LENGTH, + }, + ) + return url def register_guidance_tools(app: FastMCP) -> None: diff --git a/tests/unit_tests/test_mcp_guidance.py b/tests/unit_tests/test_mcp_guidance.py index c2da859b9..66b443a5b 100644 --- a/tests/unit_tests/test_mcp_guidance.py +++ b/tests/unit_tests/test_mcp_guidance.py @@ -17,6 +17,7 @@ MCP_DOMAINS_ENV_VAR, MCP_READONLY_MODE_ENV_VAR, ) +from airbyte.exceptions import AirbyteLibInputError from airbyte.mcp import guidance @@ -90,6 +91,32 @@ def test_get_github_issue_creation_link_is_listed_by_app() -> None: assert "get_github_issue_creation_link" in _list_tool_names(_airbyte_mcp_app()) +def test_get_github_issue_creation_link_rejects_oversized_encoded_description() -> None: + with pytest.raises(AirbyteLibInputError) as exc_info: + guidance.get_github_issue_creation_link( + guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, + "Issue", + "😀" * 4000, + ) + + assert exc_info.value.context["url_length"] > guidance.MAX_GITHUB_ISSUE_URL_LENGTH + assert ( + exc_info.value.context["max_url_length"] == guidance.MAX_GITHUB_ISSUE_URL_LENGTH + ) + + +def test_get_github_issue_creation_link_accepts_ascii_description_at_field_limit() -> ( + None +): + url = guidance.get_github_issue_creation_link( + guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, + "Issue", + "a" * 4000, + ) + + assert len(url) <= guidance.MAX_GITHUB_ISSUE_URL_LENGTH + + @pytest.mark.parametrize( ("hosted", "api_key", "retrieval_api_url", "expected_visible"), [ From 14dc1cb08c456f1519b6da86c1af5c7d187891e6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:40:17 +0000 Subject: [PATCH 3/3] fix(mcp): warn instead of failing on long GitHub issue links Co-Authored-By: AJ Steers --- airbyte/mcp/guidance.py | 37 ++++++++------- tests/unit_tests/test_mcp_guidance.py | 46 ++++++++++++------- tests/unit_tests/test_mcp_tool_annotations.py | 2 +- 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/airbyte/mcp/guidance.py b/airbyte/mcp/guidance.py index 0984526d9..7e8d039f0 100644 --- a/airbyte/mcp/guidance.py +++ b/airbyte/mcp/guidance.py @@ -69,6 +69,13 @@ class GitHubIssueCategory(StrEnum): CLOUD_MCP_FEATURE_REQUEST = "cloud-mcp-feature-request" +class GitHubIssueCreationLink(BaseModel): + """A pre-filled GitHub issue creation link.""" + + url: str + warning: str | None = None + + KAPA_API_KEY_CONFIG_ARG = MCPServerConfigArg( name="kapa_api_key", env_var=KAPA_API_KEY_ENV_VAR, @@ -365,19 +372,18 @@ def get_github_issue_creation_link( "and the tool calls involved." ), min_length=1, - max_length=4000, ), TraceArg.FINGERPRINT, ], -) -> str: +) -> GitHubIssueCreationLink: """Create a link to a pre-filled public GitHub issue for the Airbyte Cloud MCP server. Use this when a user encounters a bug in this server's tools or asks for a capability it - lacks. Always give the returned URL to the user as a clickable link. This tool does not file - anything: the user must be signed in to GitHub to review and submit the issue under their own - account. Issues are public, so never include secrets, credentials, connector config values, or - customer data. Draft the description in Markdown with what happened, what was expected, and - the tool calls involved. + lacks. Always give the returned URL to the user as a clickable link. If `warning` is set, the + link may be too long to open; follow its guidance. This tool does not file anything: the user + must be signed in to GitHub to review and submit the issue under their own account. Issues are + public, so never include secrets, credentials, connector config values, or customer data. Draft + the description in Markdown with what happened, what was expected, and the tool calls involved. """ url = f"https://github.com/{GITHUB_ISSUES_REPO}/issues/new?" + urlencode( { @@ -387,18 +393,15 @@ def get_github_issue_creation_link( }, quote_via=cast(Any, quote), ) + warning = None if len(url) > MAX_GITHUB_ISSUE_URL_LENGTH: - raise exc.AirbyteLibInputError( - message=( - "The issue link is too long after URL encoding. " - "Shorten the title or description and try again." - ), - context={ - "url_length": len(url), - "max_url_length": MAX_GITHUB_ISSUE_URL_LENGTH, - }, + warning = ( + f"This link is {len(url)} characters after URL encoding, above the " + f"{MAX_GITHUB_ISSUE_URL_LENGTH}-character limit some browsers and servers enforce. " + "Give the user the link anyway. If it fails to open, shorten the description and " + "create a new link." ) - return url + return GitHubIssueCreationLink(url=url, warning=warning) def register_guidance_tools(app: FastMCP) -> None: diff --git a/tests/unit_tests/test_mcp_guidance.py b/tests/unit_tests/test_mcp_guidance.py index 66b443a5b..0ce8d78e9 100644 --- a/tests/unit_tests/test_mcp_guidance.py +++ b/tests/unit_tests/test_mcp_guidance.py @@ -17,7 +17,6 @@ MCP_DOMAINS_ENV_VAR, MCP_READONLY_MODE_ENV_VAR, ) -from airbyte.exceptions import AirbyteLibInputError from airbyte.mcp import guidance @@ -73,11 +72,11 @@ def test_get_github_issue_creation_link( "Expected: success.\nTool calls: `get_cloud_sync_status`." ) - parsed_url = urlsplit( - guidance.get_github_issue_creation_link(category, title, description) - ) + result = guidance.get_github_issue_creation_link(category, title, description) + parsed_url = urlsplit(result.url) query = parse_qs(parsed_url.query) + assert result.warning is None assert parsed_url.scheme == "https" assert parsed_url.netloc == "github.com" assert parsed_url.path == "/airbytehq/PyAirbyte/issues/new" @@ -91,30 +90,43 @@ def test_get_github_issue_creation_link_is_listed_by_app() -> None: assert "get_github_issue_creation_link" in _list_tool_names(_airbyte_mcp_app()) -def test_get_github_issue_creation_link_rejects_oversized_encoded_description() -> None: - with pytest.raises(AirbyteLibInputError) as exc_info: - guidance.get_github_issue_creation_link( - guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, - "Issue", - "😀" * 4000, - ) - - assert exc_info.value.context["url_length"] > guidance.MAX_GITHUB_ISSUE_URL_LENGTH - assert ( - exc_info.value.context["max_url_length"] == guidance.MAX_GITHUB_ISSUE_URL_LENGTH +def test_get_github_issue_creation_link_warns_for_oversized_encoded_description() -> ( + None +): + result = guidance.get_github_issue_creation_link( + guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, + "Issue", + "😀" * 4000, ) + assert result.warning is not None + assert "shorten the description" in result.warning + assert result.url.startswith("https://github.com/airbytehq/PyAirbyte/issues/new?") + def test_get_github_issue_creation_link_accepts_ascii_description_at_field_limit() -> ( None ): - url = guidance.get_github_issue_creation_link( + result = guidance.get_github_issue_creation_link( guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, "Issue", "a" * 4000, ) - assert len(url) <= guidance.MAX_GITHUB_ISSUE_URL_LENGTH + assert len(result.url) <= guidance.MAX_GITHUB_ISSUE_URL_LENGTH + assert result.warning is None + + +def test_get_github_issue_creation_link_warns_for_long_ascii_description() -> None: + result = guidance.get_github_issue_creation_link( + guidance.GitHubIssueCategory.CLOUD_MCP_BUG_REPORT, + "Issue", + "a" * 10000, + ) + + assert result.warning is not None + assert "shorten the description" in result.warning + assert result.url.startswith("https://github.com/airbytehq/PyAirbyte/issues/new?") @pytest.mark.parametrize( diff --git a/tests/unit_tests/test_mcp_tool_annotations.py b/tests/unit_tests/test_mcp_tool_annotations.py index 0b1aa30d2..b6e523b56 100644 --- a/tests/unit_tests/test_mcp_tool_annotations.py +++ b/tests/unit_tests/test_mcp_tool_annotations.py @@ -28,7 +28,7 @@ def test_check_cloud_connector_is_read_only_and_idempotent() -> None: def test_get_github_issue_creation_link_is_read_only_and_idempotent() -> None: - """`get_github_issue_creation_link` only returns a GitHub URL.""" + """`get_github_issue_creation_link` only constructs a GitHub URL.""" (annotations,) = [ a for f, a in _REGISTERED_TOOLS