From 9bd8ba4d0a69424b74a6ba859133aacf68077fc8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:13:28 +0000 Subject: [PATCH 1/2] feat: Add airbyte-mcp-cli tool for direct MCP tool invocation - Add new CLI entry point 'airbyte-mcp-cli' for calling MCP tools directly - Support JSON input, named arguments, and mixed input styles - Extract and apply Pydantic Field defaults for optional parameters - Add --list-tools and --help-tool options for tool discovery - Enable usage with uvx for one-off invocations without installation Co-Authored-By: AJ Steers --- airbyte/mcp/cli.py | 257 +++++++++++++++++++++++++++++++++++++++++++++ poetry.lock | 12 +++ pyproject.toml | 1 + 3 files changed, 270 insertions(+) create mode 100644 airbyte/mcp/cli.py diff --git a/airbyte/mcp/cli.py b/airbyte/mcp/cli.py new file mode 100644 index 000000000..9c64d7448 --- /dev/null +++ b/airbyte/mcp/cli.py @@ -0,0 +1,257 @@ +# Copyright (c) 2024 Airbyte, Inc., all rights reserved. +r"""CLI for calling Airbyte MCP tools directly. + +This module provides a command-line interface for invoking MCP tools without +running the full MCP server. It's designed to work with uvx for one-off invocations. + +Example usage: + uvx --from=airbyte airbyte-mcp-cli validate_connector_config \ + '{"connector_name": "source-faker"}' + + uvx --from=airbyte airbyte-mcp-cli validate_connector_config \ + --connector_name=source-faker + + uvx --from=airbyte airbyte-mcp-cli list_connectors \ + '{"keyword_filter": "faker"}' --connector_type_filter=source + + uvx --from=airbyte airbyte-mcp-cli --list-tools + + uvx --from=airbyte airbyte-mcp-cli validate_connector_config --help +""" + +from __future__ import annotations + +import inspect +import json +import sys +import traceback +from typing import Any, get_type_hints + +import click + +from airbyte._util.meta import set_mcp_mode +from airbyte.mcp._tool_utils import get_registered_tools +from airbyte.mcp._util import initialize_secrets + + +set_mcp_mode() +initialize_secrets() + +from airbyte.mcp import cloud_ops, connector_registry, local_ops # noqa: E402, F401 + + +def _parse_value(value: str) -> Any: # noqa: ANN401 + """Parse a string value into appropriate Python type. + + Attempts to parse as JSON first. If that fails, returns the string as-is. + + Args: + value: The string value to parse + + Returns: + Parsed value (could be dict, list, int, bool, str, etc.) + """ + try: + return json.loads(value) + except (json.JSONDecodeError, ValueError): + return value + + +def _get_tool_function(tool_name: str) -> tuple[Any, dict[str, Any]] | None: + """Get the tool function and its annotations by name. + + Args: + tool_name: Name of the tool to find + + Returns: + Tuple of (function, annotations) or None if not found + """ + all_tools = get_registered_tools() + for func, tool_annotations in all_tools: + if func.__name__ == tool_name: + return func, tool_annotations + return None + + +def _list_tools() -> None: + """List all available MCP tools grouped by domain.""" + all_tools = get_registered_tools() + + tools_by_domain: dict[str, list[tuple[Any, dict[str, Any]]]] = {} + for func, tool_annotations in all_tools: + domain = tool_annotations.get("domain", "unknown") + if domain not in tools_by_domain: + tools_by_domain[domain] = [] + tools_by_domain[domain].append((func, tool_annotations)) + + click.echo("Available MCP Tools:\n") + + for domain in sorted(tools_by_domain.keys()): + click.echo(f"[{domain.upper()}]") + for func, tool_annotations in sorted( + tools_by_domain[domain], key=lambda x: x[0].__name__ + ): + tool_name = func.__name__ + doc = (func.__doc__ or "").strip().split("\n")[0] + read_only = " [read-only]" if tool_annotations.get("readOnlyHint") else "" + click.echo(f" {tool_name}{read_only}") + if doc: + click.echo(f" {doc}") + click.echo() + + +def _show_tool_help(tool_name: str) -> None: + """Show detailed help for a specific tool. + + Args: + tool_name: Name of the tool to show help for + """ + tool_info = _get_tool_function(tool_name) + if not tool_info: + click.echo(f"Error: Tool '{tool_name}' not found.", err=True) + click.echo("\nUse --list-tools to see available tools.", err=True) + sys.exit(1) + + func, annotations = tool_info + + click.echo(f"Tool: {tool_name}") + click.echo(f"Domain: {annotations.get('domain', 'unknown')}") + + if func.__doc__: + click.echo(f"\n{func.__doc__.strip()}") + + sig = inspect.signature(func) + if sig.parameters: + click.echo("\nParameters:") + for param_name, param in sig.parameters.items(): + param_type = param.annotation if param.annotation != inspect.Parameter.empty else "Any" + default = ( + f" (default: {param.default})" if param.default != inspect.Parameter.empty else "" + ) + click.echo(f" --{param_name}: {param_type}{default}") + + hints = [] + if annotations.get("readOnlyHint"): + hints.append("read-only") + if annotations.get("idempotentHint"): + hints.append("idempotent") + if annotations.get("destructiveHint"): + hints.append("destructive") + if hints: + click.echo(f"\nProperties: {', '.join(hints)}") + + +@click.command( + context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, + help=__doc__, +) +@click.argument("tool_name", required=False) +@click.argument("json_input", required=False) +@click.option( + "--list-tools", + is_flag=True, + help="List all available MCP tools", +) +@click.option( + "--help-tool", + is_flag=True, + help="Show detailed help for the specified tool", +) +@click.pass_context +def main( # noqa: PLR0912, PLR0915 + ctx: click.Context, + tool_name: str | None, + json_input: str | None, + list_tools: bool, # noqa: FBT001 + help_tool: bool, # noqa: FBT001 +) -> None: + """CLI for calling Airbyte MCP tools directly. + + TOOL_NAME: Name of the MCP tool to call + + JSON_INPUT: Optional JSON string with tool arguments + """ + if list_tools: + _list_tools() + sys.exit(0) + + if not tool_name: + click.echo("Error: TOOL_NAME is required (or use --list-tools)", err=True) + click.echo(ctx.get_help()) + sys.exit(1) + + if help_tool: + _show_tool_help(tool_name) + sys.exit(0) + + tool_info = _get_tool_function(tool_name) + if not tool_info: + click.echo(f"Error: Tool '{tool_name}' not found.", err=True) + click.echo("\nUse --list-tools to see available tools.", err=True) + sys.exit(1) + + func, _tool_annotations = tool_info + + args: dict[str, Any] = {} + + if json_input and json_input.startswith("--"): + ctx.args = [json_input, *ctx.args] + json_input = None + + if json_input: + try: + args = json.loads(json_input) + if not isinstance(args, dict): + click.echo( + f"Error: JSON input must be an object/dict, got {type(args).__name__}", + err=True, + ) + sys.exit(1) + except json.JSONDecodeError as e: + click.echo(f"Error: Invalid JSON input: {e}", err=True) + sys.exit(1) + + for arg in ctx.args: + if arg.startswith("--"): + if "=" in arg: + key, value = arg[2:].split("=", 1) + args[key] = _parse_value(value) + else: + args[arg[2:]] = True + + sig = inspect.signature(func) + hints = get_type_hints(func, include_extras=True) + + final_args: dict[str, Any] = {} + for param_name in sig.parameters: + if param_name in args: + final_args[param_name] = args[param_name] + else: + param_hint = hints.get(param_name) + if param_hint and hasattr(param_hint, "__metadata__"): + for metadata in param_hint.__metadata__: + if hasattr(metadata, "default"): + final_args[param_name] = metadata.default + break + + try: + result = func(**final_args) + + if result is not None: + if isinstance(result, (dict, list, tuple)): + click.echo(json.dumps(result, indent=2, default=str)) + else: + click.echo(result) + + except TypeError as e: + click.echo(f"Error calling tool '{tool_name}': {e}", err=True) + click.echo(f"\nUse: airbyte-mcp-cli {tool_name} --help-tool", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error executing tool '{tool_name}': {e}", err=True) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index d6cb677d5..a8e08b3ba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1633,6 +1633,8 @@ files = [ {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"}, {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, @@ -1642,6 +1644,8 @@ files = [ {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"}, {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, @@ -1651,6 +1655,8 @@ files = [ {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"}, {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, @@ -1660,6 +1666,8 @@ files = [ {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"}, {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, @@ -1667,6 +1675,8 @@ files = [ {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"}, {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, @@ -1676,6 +1686,8 @@ files = [ {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"}, {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, diff --git a/pyproject.toml b/pyproject.toml index 6518715cc..f3ae966ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,7 @@ venv = ".venv" pyairbyte = "airbyte.cli:cli" pyab = "airbyte.cli:cli" airbyte-mcp = "airbyte.mcp.server:main" +airbyte-mcp-cli = "airbyte.mcp.cli:main" [tool.poe.tasks] test = { shell = "pytest" } From d3c30ff75ad8b49f1a698b5ee88aa3640dbdfed3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:16:50 +0000 Subject: [PATCH 2/2] style: Apply ruff formatting to cli.py Co-Authored-By: AJ Steers --- airbyte/mcp/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/airbyte/mcp/cli.py b/airbyte/mcp/cli.py index 9c64d7448..b1ba5f631 100644 --- a/airbyte/mcp/cli.py +++ b/airbyte/mcp/cli.py @@ -88,9 +88,7 @@ def _list_tools() -> None: for domain in sorted(tools_by_domain.keys()): click.echo(f"[{domain.upper()}]") - for func, tool_annotations in sorted( - tools_by_domain[domain], key=lambda x: x[0].__name__ - ): + for func, tool_annotations in sorted(tools_by_domain[domain], key=lambda x: x[0].__name__): tool_name = func.__name__ doc = (func.__doc__ or "").strip().split("\n")[0] read_only = " [read-only]" if tool_annotations.get("readOnlyHint") else ""