blob: 4dc8b3dda8e0ddb4e4a56ea6c8ca7bcecba4d26d [file]
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import os
import sys
import json
from typing import Any, Optional
import traceback
from pathlib import Path
import io
import contextlib
# Add the SDK root to sys.path for absolute imports
# This file is in interface/cli.py, so root is two levels up
sdk_root = Path(__file__).resolve().parent.parent
if str(sdk_root) not in sys.path:
sys.path.insert(0, str(sdk_root))
from interface.errors import ToolError
from api.env import BuildContext
# Import defs to ensure tools are registered
import interface.defs
from interface.registry import TOOLS
from interface.server import MCPServer
def generate_mcp_config_data() -> dict[str, Any]:
"""Generates the MCP configuration dictionary."""
# Note: We do not need to list tools here. The Agent discovers tools dynamically at runtime.
# The output structure is for mcpServers configuration.
return {
"mcpServers": {
"android_build": {
"command": "python3",
"args": [
"${ANDROID_BUILD_TOP}/build/soong/scripts/ai/android_build/interface/cli.py"
],
"env": {
"PYTHONPATH": "${ANDROID_BUILD_TOP}/build/soong/scripts/ai/android_build"
}
}
}
}
def main() -> None:
# If no arguments are provided, run as MCP Server
if len(sys.argv) == 1:
server = MCPServer()
server.run()
sys.exit(0)
parser = argparse.ArgumentParser(description="Android Build AI Agent Interface")
parser.add_argument("--debug", action="store_true", help="Enable debug output (tracebacks)")
parser.add_argument("--print-registration-cmd", action="store_true", help="Print the command to register this MCP server")
parser.add_argument("--generate-mcp-config", action="store_true", help="Generate the mcp.json configuration file")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Dynamically generate subcommands from the registry
for tool_name in TOOLS:
subparser = subparsers.add_parser(tool_name, help=f"Run {tool_name}")
# Note: --args-json is removed from mcp.json generation but required for CLI execution by Agent
subparser.add_argument("--args-json", type=str, required=True, help="JSON arguments for the tool")
args = parser.parse_args()
# Path to the current script
current_script = Path(__file__).resolve()
# SDK root is two levels up from interface/cli.py
if current_script.parent.name == "interface":
sdk_root = current_script.parent.parent
else:
sdk_root = current_script.parent
# settings.json location: Current Working Directory for output
settings_file = Path.cwd() / "settings.json"
if args.generate_mcp_config:
config_data = generate_mcp_config_data()
with open(settings_file, "w") as f:
json.dump(config_data, f, indent=2)
print(f"Generated {settings_file}")
sys.exit(0)
if args.print_registration_cmd:
# Generate config in-memory
config_data = generate_mcp_config_data()
server_config = config_data.get("mcpServers", {}).get("android_build")
if not server_config:
print("Error: Invalid configuration (missing 'mcpServers.android_build').", file=sys.stderr)
sys.exit(1)
android_build_top = os.getenv('ANDROID_BUILD_TOP', sdk_root.parent.parent.parent.parent.parent)
cmd = server_config["command"]
script_args = [arg.replace("${ANDROID_BUILD_TOP}", str(android_build_top)) for arg in server_config["args"]]
env_vars = {k: v.replace("${ANDROID_BUILD_TOP}", str(android_build_top)) for k, v in server_config.get("env", {}).items()}
# Format the command according to gemini mcp add help:
# gemini mcp add [options] <name> <commandOrUrl> [args...]
# Positional arguments: name, commandOrUrl, args...
name = "android_build"
command_or_url = cmd
args_str = " ".join([f"'{arg}'" for arg in script_args])
# Options
env_str = " ".join([f"-e {k}='{v}'" for k, v in env_vars.items()])
print(f"gemini mcp add {name} {command_or_url} {args_str} {env_str} --trust")
sys.exit(0)
if not args.command:
parser.print_help()
sys.exit(1)
tool_def = TOOLS[args.command]
debug_mode = args.debug
try:
data = json.loads(args.args_json)
tool_args_obj = tool_def.args_model.from_dict(data)
if hasattr(tool_args_obj, 'product') and hasattr(tool_args_obj, 'release') and hasattr(tool_args_obj, 'variant'):
args_env_vars = getattr(tool_args_obj, 'env_vars', None)
ctx = BuildContext(tool_args_obj.product, tool_args_obj.release, tool_args_obj.variant, args_env_vars)
else:
raise ToolError(f"Tool arguments for '{args.command}' must contain product, release, and variant.")
output_text = ""
error_text = ""
f_out = io.StringIO()
f_err = io.StringIO()
def progress_callback(current: float, total: Optional[float] = None) -> None:
msg = {"mcp_type": "progress", "current": current}
if total is not None:
msg["total"] = total
sys.__stderr__.write(json.dumps(msg) + "\n")
sys.__stderr__.flush()
try:
with contextlib.redirect_stdout(f_out), contextlib.redirect_stderr(f_err):
try:
result = tool_def.implementation(ctx, tool_args_obj, progress_callback=progress_callback)
except TypeError as e:
if "unexpected keyword argument 'progress_callback'" in str(e):
result = tool_def.implementation(ctx, tool_args_obj)
else:
raise e
finally:
output_text = f_out.getvalue()
error_text = f_err.getvalue()
# Build output envelope
response = {
"status": "success",
"result": result
}
if output_text:
response["stdout"] = output_text
if error_text:
response["stderr"] = error_text
print(json.dumps(response, indent=2))
except Exception as e:
error_response = {
"status": "error",
"message": str(e)
}
if debug_mode:
error_response["traceback"] = traceback.format_exc()
print(json.dumps(error_response, indent=2))
sys.exit(1)
if __name__ == "__main__":
main()