diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index 2e6613e4..e849dde0 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -27,7 +27,6 @@ from arcade_cli.display import ( display_eval_results, ) from arcade_cli.show import show_logic -from arcade_cli.toolkit_docs import generate_toolkit_docs from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( Provider, @@ -707,115 +706,6 @@ def dashboard( handle_cli_error("Failed to open dashboard", e, debug) -@cli.command( - help=( - "Generate documentation for a server. " - "Note: make sure to have the server installed in your current Python environment " - "before running this command." - ), - rich_help_panel="Document", - hidden=True, -) -def docs( - server_name: str = typer.Option( - ..., - "--server-name", - "-n", - help="The name of the server to generate documentation for.", - ), - server_dir: str = typer.Option( - ..., - "--server-dir", - "-t", - help=( - "The path to the server root directory (where the server code is implemented). " - "Works with relative and absolute paths." - ), - ), - docs_dir: str = typer.Option( - ..., - "--docs-dir", - "-r", - help="The path to the root of the Arcade docs repository. Works with relative and absolute paths.", - ), - docs_section: str = typer.Option( - "", - "--docs-section", - "-s", - help=( - "The section of the docs to generate documentation for. E.g. 'productivity', 'sales'. " - "This should be the name of the folder in /pages/tools. " - "Defaults to an empty string (generate the docs in the root of /pages/tools)" - ), - ), - openai_model: str = typer.Option( - "gpt-5-mini", - "--openai-model", - "-m", - help=( - "A few parts of the documentation are generated using OpenAI API. " - "Choose one of the 'gpt-4o' and 'gpt-5' series models." - ), - show_default=True, - ), - openai_api_key: str = typer.Option( - None, - "--openai-api-key", - "-o", - help="The OpenAI API key. If not provided, will get it from the `OPENAI_API_KEY` env var.", - ), - skip_tool_call_examples: bool = typer.Option( - False, - "--skip-tool-call-examples", - "-se", - help="Whether to skip generating tool call examples in Python and Javascript.", - show_default=True, - ), - debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), -) -> None: - if not openai_model.startswith("gpt-4o") and not openai_model.startswith("gpt-5"): - console.print( - f"Attention: '{openai_model}' is not a valid OpenAI model. " - "Please choose one of the 'gpt-4o' and 'gpt-5' series models.", - style="bold red", - ) - handle_cli_error( - f"Attention: '{openai_model}' is not a valid OpenAI model. " - "Please choose one of the 'gpt-4o' and 'gpt-5' series models." - ) - - try: - success = generate_toolkit_docs( - console=console, - toolkit_name=server_name, - toolkit_dir=server_dir, - docs_dir=docs_dir, - docs_section=docs_section, - openai_model=openai_model, - openai_api_key=openai_api_key, - tool_call_examples=not skip_tool_call_examples, - debug=debug, - ) - except Exception as error: - handle_cli_error( - message=f"Failed to generate documentation for '{server_name}' in '{docs_dir}'", - error=error, - debug=debug, - ) - success = False - - if success: - console.print( - f"Generated documentation for '{server_name}' in '{docs_dir}'", - style="bold green", - ) - else: - console.print( - f"Failed to generate documentation for '{server_name}' in '{docs_dir}'", - style="bold red", - ) - - @cli.callback() def main_callback( ctx: typer.Context, diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py b/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py deleted file mode 100644 index b13ab96b..00000000 --- a/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py +++ /dev/null @@ -1,85 +0,0 @@ -from functools import partial - -import openai -from rich.console import Console - -from arcade_cli.toolkit_docs.docs_builder import ( - build_example_path, - build_examples, - build_toolkit_mdx, - build_toolkit_mdx_file_path, -) -from arcade_cli.toolkit_docs.utils import ( - get_all_enumerations, - get_list_of_tools, - has_wrapper_tools_directory, - print_debug_func, - read_toolkit_metadata, - resolve_api_key, - standardize_dir_path, - write_file, -) - - -def generate_toolkit_docs( - console: Console, - toolkit_name: str, - toolkit_dir: str, - docs_section: str, - docs_dir: str, - openai_model: str, - openai_api_key: str | None = None, - tool_call_examples: bool = True, - debug: bool = False, -) -> bool: - openai.api_key = resolve_api_key(openai_api_key, "OPENAI_API_KEY") - - if not openai.api_key: - console.print( - "❌ Provide --openai-api-key argument or set the OPENAI_API_KEY environment variable", - style="red", - ) - return False - - print_debug = partial(print_debug_func, debug, console) - - docs_dir = standardize_dir_path(docs_dir) - toolkit_dir = standardize_dir_path(toolkit_dir) - is_wrapper_toolkit = has_wrapper_tools_directory(toolkit_dir) - - print_debug("Reading server metadata") - pip_package_name = read_toolkit_metadata(toolkit_dir) - - print_debug(f"Getting list of tools for {toolkit_name} from the local Python environment") - tools = get_list_of_tools(toolkit_name) - - print_debug(f"Found {len(tools)} tools") - - print_debug("Getting all enumerations potentially used in tool argument specs") - enums = get_all_enumerations(toolkit_dir) - - toolkit_mdx_file_path = build_toolkit_mdx_file_path(docs_section, docs_dir, toolkit_name) - print_debug(f"Building {toolkit_mdx_file_path} file") - toolkit_mdx = build_toolkit_mdx( - toolkit_package_name=toolkit_name, - toolkit_dir=toolkit_dir, - tools=tools, - docs_section=docs_section, - enums=enums, - pip_package_name=pip_package_name, - openai_model=openai_model, - is_wrapper_toolkit=is_wrapper_toolkit, - ) - write_file(toolkit_mdx_file_path, toolkit_mdx) - - if tool_call_examples: - print_debug("Building tool-call examples in Python and JavaScript") - examples = build_examples(print_debug, tools, openai_model) - - for filename, example in examples: - example_path = build_example_path(filename, docs_dir, toolkit_name) - write_file(example_path, example) - - print_debug(f"Done generating docs for {toolkit_name}") - - return True diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py b/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py deleted file mode 100644 index e337c199..00000000 --- a/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py +++ /dev/null @@ -1,603 +0,0 @@ -import json -import os -import pprint -from enum import Enum -from typing import Any, Callable, cast - -import openai -from arcade_core import auth as auth_module -from arcade_core.schema import ( - ToolAuthRequirement, - ToolDefinition, - ToolInput, - ToolSecretRequirement, -) -from rich.console import Console - -from arcade_cli.toolkit_docs.templates import ( - ENUM_ITEM, - ENUM_MDX, - ENUM_VALUE, - GENERIC_PROVIDER_CONFIG, - STARTER_TOOL_INFO_CALL, - STARTER_TOOLKIT_HEADER_IMPORT, - TABBED_EXAMPLES_LIST, - TABLE_OF_CONTENTS, - TABLE_OF_CONTENTS_ITEM, - TOOL_CALL_EXAMPLE_JS, - TOOL_CALL_EXAMPLE_PY, - TOOL_PARAMETER, - TOOL_SPEC, - TOOL_SPEC_SECRETS, - TOOLKIT_FOOTER, - TOOLKIT_FOOTER_OAUTH2, - TOOLKIT_HEADER, - TOOLKIT_PAGE, - WELL_KNOWN_PROVIDER_CONFIG, -) -from arcade_cli.toolkit_docs.utils import ( - clean_fully_qualified_name, - find_enum_by_options, - find_pyproject_toml, - get_pyproject_description, - get_toolkit_auth_type, - is_well_known_provider, - pascal_to_snake_case, -) - -console = Console() - - -def build_toolkit_mdx_dir_path( - docs_section: str, - docs_root_dir: str, - toolkit_name: str, - ensure_exists: bool = True, -) -> str: - dir_path = os.path.join( - docs_root_dir, - "app", - "en", - "mcp-servers", - docs_section, - f"{toolkit_name.lower().replace('_', '-')}", - ) - - if ensure_exists: - os.makedirs(dir_path, exist_ok=True) - return dir_path - - -def build_toolkit_mdx_file_path(docs_section: str, docs_root_dir: str, toolkit_name: str) -> str: - toolkit_dir_path = build_toolkit_mdx_dir_path(docs_section, docs_root_dir, toolkit_name) - return os.path.join(toolkit_dir_path, "page.mdx") - - -def build_example_path(example_filename: str, docs_root_dir: str, toolkit_name: str) -> str: - return os.path.join( - docs_root_dir, - "public", - "examples", - "integrations", - "mcp-servers", - toolkit_name.lower(), - example_filename, - ) - - -def build_toolkit_mdx( - toolkit_package_name: str, - toolkit_dir: str, - tools: list[ToolDefinition], - docs_section: str, - enums: dict[str, type[Enum]], - pip_package_name: str, - openai_model: str, - toolkit_header_template: str = TOOLKIT_HEADER, - toolkit_page_template: str = TOOLKIT_PAGE, - is_wrapper_toolkit: bool = False, -) -> tuple[str, str]: - sample_tool = tools[0] - toolkit_name = sample_tool.toolkit.name - toolkit_version = sample_tool.toolkit.version - auth_type = get_toolkit_auth_type(sample_tool.requirements) - - if is_wrapper_toolkit: - starter_tool_info_import = STARTER_TOOLKIT_HEADER_IMPORT - starter_tool_info_warning = STARTER_TOOL_INFO_CALL.format(toolkit_name=toolkit_name) - else: - starter_tool_info_import = "" - starter_tool_info_warning = "" - - try: - pyproject_path = find_pyproject_toml(toolkit_dir) - tool_info_description = get_pyproject_description(pyproject_path) - - except ValueError: - tool_info_description = f"Enable Agents to interact with the {toolkit_name} MCP Server" - - header = toolkit_header_template.format( - toolkit_title=toolkit_name, - tool_info_description=tool_info_description, - starter_tool_info_import=starter_tool_info_import, - starter_tool_info_warning=starter_tool_info_warning, - description=generate_toolkit_description( - toolkit_name, - [(tool.name, tool.description) for tool in tools], - openai_model, - ), - pip_package_name=pip_package_name, - auth_type=auth_type, - version=toolkit_version, - ) - table_of_contents = build_table_of_contents(tools) - footer = build_footer(toolkit_name, pip_package_name, sample_tool.requirements.authorization) - - referenced_enums, tools_specs = build_tools_specs( - toolkit_package_name, tools, docs_section, enums - ) - reference_mdx = build_reference_mdx(toolkit_name, referenced_enums) if referenced_enums else "" - - toolkit_mdx = toolkit_page_template.format( - header=header, - table_of_contents=table_of_contents, - tools_specs=tools_specs, - reference_mdx=reference_mdx, - footer=footer, - ) - - return toolkit_mdx.strip() - - -def build_reference_mdx( - toolkit_name: str, - referenced_enums: list[tuple[str, type[Enum]]], - enum_item_template: str = ENUM_ITEM, - enum_value_template: str = ENUM_VALUE, - enum_mdx_template: str = ENUM_MDX, -) -> str: - enum_items = "" - enum_names_seen = set() - - for enum_name, enum_class in referenced_enums: - if enum_name in enum_names_seen: - continue - enum_names_seen.add(enum_name) - enum_items += enum_item_template.format( - enum_name=enum_name, - enum_values=build_enum_values( - enum_class=enum_class, - enum_value_template=enum_value_template, - ), - ) - - return enum_mdx_template.format( - toolkit_name=toolkit_name, - enum_items=enum_items, - ) - - -def build_enum_values( - enum_class: type[Enum], - enum_value_template: str = ENUM_VALUE, -) -> str: - enum_values = "" - for enum_member in enum_class: - enum_values += ( - enum_value_template.format( - enum_option_name=enum_member.name, - enum_option_value=enum_member.value, - ) - + "\n" - ) - return enum_values - - -def build_table_of_contents( - tools: list[ToolDefinition], - table_of_contents_item_template: str = TABLE_OF_CONTENTS_ITEM, - table_of_contents_template: str = TABLE_OF_CONTENTS, -) -> str: - tools_items = "" - - for tool in tools: - tools_items += table_of_contents_item_template.format( - tool_fully_qualified_name=clean_fully_qualified_name(tool.fully_qualified_name), - description=tool.description.split("\n")[0], - ) - - return table_of_contents_template.format(tool_items=tools_items) - - -def build_footer( - toolkit_name: str, - pip_package_name: str, - authorization: ToolAuthRequirement | None, - footer_template: str = TOOLKIT_FOOTER, - oauth2_footer_template: str = TOOLKIT_FOOTER_OAUTH2, - well_known_provider_config_template: str = WELL_KNOWN_PROVIDER_CONFIG, - generic_provider_config_template: str = GENERIC_PROVIDER_CONFIG, -) -> str: - if authorization and authorization.provider_type == "oauth2" and authorization.provider_id: - is_well_known = is_well_known_provider( - provider_id=authorization.provider_id, - auth_module=auth_module, - ) - config_template = ( - well_known_provider_config_template - if is_well_known - else generic_provider_config_template - ) - provider_configuration = config_template.format( - toolkit_name=toolkit_name, - provider_id=authorization.provider_id, - provider_name=authorization.provider_id.capitalize(), - ) - - return oauth2_footer_template.format( - pip_package_name=pip_package_name, - provider_configuration=provider_configuration, - ) - return footer_template.format(toolkit_name=toolkit_name, pip_package_name=pip_package_name) - - -def build_tools_specs( - toolkit_name: str, - tools: list[ToolDefinition], - docs_section: str, - enums: dict[str, type[Enum]], - tool_spec_template: str = TOOL_SPEC, - tool_parameter_template: str = TOOL_PARAMETER, - tool_spec_secrets_template: str = TOOL_SPEC_SECRETS, -) -> tuple[list[tuple[str, type[Enum]]], str]: - tools_specs = "" - referenced_enums = [] - for tool in tools: - tool_referenced_enums, tool_spec = build_tool_spec( - toolkit_name=toolkit_name, - tool=tool, - docs_section=docs_section, - enums=enums, - tool_spec_template=tool_spec_template, - tool_parameter_template=tool_parameter_template, - tool_spec_secrets_template=tool_spec_secrets_template, - ) - tools_specs += tool_spec - referenced_enums.extend(tool_referenced_enums) - - return referenced_enums, tools_specs - - -def build_tool_spec( - toolkit_name: str, - tool: ToolDefinition, - docs_section: str, - enums: dict[str, type[Enum]], - tool_spec_template: str = TOOL_SPEC, - tool_parameter_template: str = TOOL_PARAMETER, - tool_spec_secrets_template: str = TOOL_SPEC_SECRETS, -) -> tuple[list[tuple[str, type[Enum]]], str]: - tabbed_examples_list = TABBED_EXAMPLES_LIST.format( - toolkit_name=toolkit_name.lower(), - tool_name=pascal_to_snake_case(tool.name), - ) - referenced_enums, parameters = build_tool_parameters( - tool_input=tool.input, - docs_section=docs_section, - toolkit_name=tool.toolkit.name.lower(), - enums=enums, - tool_parameter_template=tool_parameter_template, - ) - - if not parameters: - parameters = "This tool does not take any parameters." - - secrets = ( - build_tool_secrets( - secrets=tool.requirements.secrets, - template=tool_spec_secrets_template, - ) - if tool.requirements.secrets - else "" - ) - - return referenced_enums, tool_spec_template.format( - tool_fully_qualified_name=clean_fully_qualified_name(tool.fully_qualified_name), - tabbed_examples_list=tabbed_examples_list, - description=tool.description.split("\n")[0], - parameters=parameters, - secrets=secrets, - ) - - -def build_tool_secrets( - secrets: list[ToolSecretRequirement], - template: str = TOOL_SPEC_SECRETS, -) -> str: - if not secrets: - return "" - secret_keys_str = "`, `".join([secret.key for secret in secrets]) - return template.format(secrets=f"`{secret_keys_str}`") - - -def build_tool_parameters( - tool_input: ToolInput, - docs_section: str, - toolkit_name: str, - enums: dict[str, type[Enum]], - tool_parameter_template: str = TOOL_PARAMETER, -) -> tuple[list[tuple[str, type[Enum]]], str]: - referenced_enums = [] - parameters = "" - for parameter in tool_input.parameters: - schema = parameter.value_schema - if schema.enum: - enum_name, enum_class = find_enum_by_options(enums, schema.enum) - referenced_enums.append((enum_name, enum_class)) - param_definition = f"`Enum` [{enum_name}](/mcp-servers/{docs_section}/{toolkit_name}/reference#{enum_name})" - else: - if schema.inner_val_type: - param_definition = f"`{schema.val_type}[{schema.inner_val_type}]`" - else: - param_definition = f"`{schema.val_type}`" - - if parameter.required: - param_definition += ", required" - else: - param_definition += ", optional" - - parameters += ( - tool_parameter_template.format( - param_name=parameter.name, - definition=param_definition, - description=parameter.description, - ) - + "\n" - ) - - return referenced_enums, parameters - - -def build_examples( - print_debug: Callable, - tools: list[ToolDefinition], - openai_model: str, -) -> list[tuple[str, str]]: - examples = [] - for tool in tools: - print_debug(f"Generating tool-call examples for {tool.name}") - interface_signature = build_tool_interface_signature(tool) - input_map = generate_tool_input_map(interface_signature, openai_model) - fully_qualified_name = tool.fully_qualified_name.split("@")[0] - - py_file_name = f"{pascal_to_snake_case(tool.name)}_example_call_tool.py" - examples.append(( - py_file_name, - build_python_example(fully_qualified_name, input_map), - )) - js_file_name = f"{pascal_to_snake_case(tool.name)}_example_call_tool.js" - examples.append(( - js_file_name, - build_javascript_example(fully_qualified_name, input_map), - )) - return examples - - -def build_python_example( - tool_fully_qualified_name: str, - input_map: dict[str, Any], - template: str = TOOL_CALL_EXAMPLE_PY, -) -> str: - input_map_str = pprint.pformat( - input_map, - indent=4, - width=100, - compact=False, - sort_dicts=False, - ) - input_map_str = "{\n " + input_map_str.lstrip("{ ").rstrip("}") + "\n}" # noqa: B005 - return template.format( - tool_fully_qualified_name=tool_fully_qualified_name, - input_map=input_map_str, - ) - - -def build_javascript_example( - tool_fully_qualified_name: str, - input_map: dict, - template: str = TOOL_CALL_EXAMPLE_JS, -) -> str: - return template.format( - tool_fully_qualified_name=tool_fully_qualified_name, - input_map=json.dumps(input_map, indent=2, ensure_ascii=False), - ) - - -def generate_toolkit_description( - toolkit_name: str, - tools: list[tuple[str, str]], - openai_model: str, -) -> str: - messages = [ - { - "role": "system", - "content": ( - "You are a helpful assistant. " - "When given an MCP Server name and a list of tools, you will generate a " - "short, yet descriptive of the MCP Server and the main actions a user " - "or LLM can perform with it.\n\n" - "As an example, here is the Asana MCP Server description:\n\n" - "The Arcade Asana MCP Server provides a pre-built set of tools for " - "interacting with Asana. These tools make it easy to build agents " - "and AI apps that can:\n\n" - "- Manage teams, projects, and workspaces.\n" - "- Create, update, and search for tasks.\n" - "- Retrieve data about tasks, projects, workspaces, users, etc.\n" - "- Manage task attachments.\n\n" - "And here is a JSON string with the list of tools in the Asana MCP Server:\n\n" - "```json\n\n" - '[["AttachFileToTask", "Attaches a file to an Asana task\n\nProvide exactly ' - "one of file_content_str, file_content_base64, or file_content_url, never " - "more\nthan one.\n\n- Use file_content_str for text files (will be encoded " - "using file_encoding)\n- Use file_content_base64 for binary files like images, " - 'PDFs, etc.\n- Use file_content_url if the file is hosted on an external URL"], ' - '["CreateTag", "Create a tag in Asana"], ["CreateTask", "Creates a task in ' - "Asana\n\nThe task must be associated to at least one of the following: " - "parent_task_id, project, or\nworkspace_id. If none of these are provided and " - "the account has only one workspace, the task\nwill be associated to that " - "workspace. If the account has multiple workspaces, an error will\nbe raised " - 'with a list of available workspaces."], ["GetProjectById", "Get an Asana ' - 'project by its ID"], ["GetSubtasksFromATask", "Get the subtasks of a task"], ' - '["GetTagById", "Get an Asana tag by its ID"], ["GetTaskById", "Get a task by ' - 'its ID"], ["GetTasksWithoutId", "Search for tasks"], ["GetTeamById", "Get an ' - 'Asana team by its ID"], ["GetUserById", "Get a user by ID"], ["GetWorkspaceById", ' - '"Get an Asana workspace by its ID"], ["ListProjects", "List projects in Asana"], ' - '["ListTags", "List tags in an Asana workspace"], ["ListTeams", "List teams in ' - 'an Asana workspace"], ["ListTeamsTheCurrentUserIsAMemberOf", "List teams in ' - 'Asana that the current user is a member of"], ["ListUsers", "List users in ' - 'Asana"], ["ListWorkspaces", "List workspaces in Asana that are visible to the ' - 'authenticated user"], ["MarkTaskAsCompleted", "Mark a task in Asana as ' - 'completed"], ["UpdateTask", "Updates a task in Asana"]]\n\n```\n\n' - "Keep the description concise and to the point. The user will provide you with " - "the MCP Server name and the list of tools. Generate the description according to " - "the instructions above." - ), - }, - { - "role": "user", - "content": ( - f"The MCP Server name is {toolkit_name} and the list of tools is:\n\n" - "```json\n\n" - f"{json.dumps(tools, ensure_ascii=False)}\n\n" - "```\n\n" - "Please generate a description for the MCP Server." - ), - }, - ] - - return request_openai_generation(model=openai_model, max_tokens=512, messages=messages) - - -def generate_tool_input_map( - interface_signature: dict[str, Any], - openai_model: str, - retries: int = 0, - max_retries: int = 3, -) -> dict[str, Any]: - messages = [ - { - "role": "system", - "content": ( - "You are a helpful assistant expert in generating data for documenting " - "sample scripts to calling tools. A tool is a function that is used in " - "context of LLM tool-calling / function-calling.\n\n" - "When given a tool signature with typed arguments, " - "you must return exactly one JSON object (no markdown, no extra text) " - "where each key is an argument name, and each value is a sample value " - "for that argument that would make sense in a sample script to showcase " - "human software engineers how the tool may be called. Generate the " - "argument sample value based on its name and description\n\n" - "Not every single argument must always be present in the input map. " - "In some cases, the tool may require only one of two arguments to be " - "provided, for example. In such cases, an indication will be present " - "either/or in the tool description or the argument description. " - "Always follow such instructions when present in the tool interface.\n\n" - "Keep argument values as short as possible. Values don't have to always " - "be valid. For instance, for file content base64-encoded arguments, " - "you can use a short text or a placeholder like `[file_content]`, it is " - "not necessary that the value is a valid base64-encoded string.\n\n" - "Remember that you MUST RESPOND ONLY WITH A VALID JSON STRING, NO ADDED " - "TEXT. Your response will be json.load'ed, so it must be a valid JSON " - "string." - ), - }, - { - "role": "user", - "content": ( - "Here is a tool interface:\n\n" - f"{json.dumps(interface_signature, ensure_ascii=False)}\n\n" - "Please provide a sample input map as a JSON object." - ), - }, - ] - - text = request_openai_generation(model=openai_model, max_tokens=512, messages=messages) - - try: - return cast(dict[str, Any], json.loads(text)) - except (json.JSONDecodeError, TypeError): - if retries < max_retries: - return generate_tool_input_map( - interface_signature=interface_signature, - openai_model=openai_model, - retries=retries + 1, - max_retries=max_retries, - ) - tool_name = interface_signature["tool_name"] - console.print( - f"Attention: {openai_model} failed to generate a valid inputs JSON for the tool '{tool_name}'. " - "Please check the Python & Javascript example scripts generated and enter a sample input manually.", - style="red", - ) - return {} - - -def build_tool_interface_signature(tool: ToolDefinition) -> dict[str, Any]: - args = [] - for arg in tool.input.parameters: - data: dict[str, Any] = { - "arg_name": arg.name, - "arg_description": arg.description, - "is_arg_required": arg.required, - "arg_type": arg.value_schema.val_type, - } - - if arg.value_schema.enum: - data["enum"] = { - "accepted_values": arg.value_schema.enum, - } - - args.append(data) - - return { - "tool_name": tool.name, - "tool_description": tool.description, - "tool_args": args, - } - - -def request_openai_generation( - model: str, - max_tokens: int, - messages: list[dict[str, Any]], -) -> str: - if model.startswith("gpt-5"): - response = openai.responses.create( - model=model, - input=messages, - max_output_tokens=max_tokens, - reasoning={ - "effort": "minimal", - }, - text={ - "verbosity": "low", - }, - ) - response_str = cast(str, response.output_text) - - elif model.startswith("gpt-4o"): - response = openai.chat.completions.create( - model=model, - messages=messages, - temperature=0.0, - max_completion_tokens=max_tokens, - stop=["\n\n"], - ) - response_str = cast(str, response.choices[0].message.content) - - else: - raise ValueError( - f"Unsupported OpenAI model: {model}. Choose a model from the 'gpt-4o' or 'gpt-5' series." - ) - - return response_str.strip() diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py b/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py deleted file mode 100644 index 399bc8d7..00000000 --- a/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py +++ /dev/null @@ -1,168 +0,0 @@ -TOOLKIT_PAGE = """{header} - -{table_of_contents} - -{tools_specs} -{reference_mdx} -{footer} -""" - -STARTER_TOOLKIT_HEADER_IMPORT = 'import StarterToolInfo from "@/app/_components/starter-tool-info";' - -STARTER_TOOL_INFO_CALL = '' - -TOOLKIT_HEADER = """# {toolkit_title} -{starter_tool_info_import} -import ToolInfo from "@/app/_components/tool-info"; -import Badges from "@/app/_components/badges"; -import TabbedCodeBlock from "@/app/_components/tabbed-code-block"; -import TableOfContents from "@/app/_components/table-of-contents"; -import ToolFooter from "@/app/_components/tool-footer"; -import {{ Callout }} from "nextra/components"; - - - - - -{starter_tool_info_warning} - -{description}""" - -TABLE_OF_CONTENTS = """## Available Tools - - - - - If you need to perform an action that's not listed here, you can [get in touch - with us](mailto:contact@arcade.dev) to request a new tool, or [create your - own tools](/home/build-tools/create-a-mcp-server). -""" - -TABLE_OF_CONTENTS_ITEM = '\n ["{tool_fully_qualified_name}", "{description}"],' - -TOOL_SPEC = """## {tool_fully_qualified_name} - -
-{tabbed_examples_list} - -{description} - -**Parameters** - -{parameters} -{secrets} -""" - -TOOL_SPEC_SECRETS = """**Secrets** - -This tool requires the following secrets: {secrets} (learn how to [configure secrets](/home/build-tools/create-a-tool-with-secrets#set-the-secret-in-the-arcade-dashboard)) -""" - -TABBED_EXAMPLES_LIST = """""" - -TOOL_PARAMETER = "- **{param_name}** ({definition}) {description}" - -TOOLKIT_FOOTER = """""" - -TOOLKIT_FOOTER_OAUTH2 = """## Auth - -{provider_configuration} - - -""" - -WELL_KNOWN_PROVIDER_CONFIG = "The Arcade {toolkit_name} MCP Server uses the [{provider_name} auth provider](/home/auth-providers/{provider_id}) to connect to users' {toolkit_name} accounts. Please refer to the [{provider_name} auth provider](/home/auth-providers/{provider_id}) documentation to learn how to configure auth." - -GENERIC_PROVIDER_CONFIG = "The {toolkit_name} MCP Server uses the Auth Provider with id `{provider_id}` to connect to users' {toolkit_name} accounts. In order to use the MCP Server, you will need to configure the `{provider_id}` auth provider." - -TOOL_CALL_EXAMPLE_JS = """import {{ Arcade }} from "@arcadeai/arcadejs"; - -const client = new Arcade(); // Automatically finds the `ARCADE_API_KEY` env variable - -const USER_ID = "{{arcade_user_id}}"; -const TOOL_NAME = "{tool_fully_qualified_name}"; - -// Start the authorization process -const authResponse = await client.tools.authorize({{tool_name: TOOL_NAME}}); - -if (authResponse.status !== "completed") {{ - console.log(`Click this link to authorize: ${{authResponse.url}}`); -}} - -// Wait for the authorization to complete -await client.auth.waitForCompletion(authResponse); - -const toolInput = {input_map}; - -const response = await client.tools.execute({{ - tool_name: TOOL_NAME, - input: toolInput, - user_id: USER_ID, -}}); - -console.log(JSON.stringify(response.output.value, null, 2)); -""" - -TOOL_CALL_EXAMPLE_PY = """import json -from arcadepy import Arcade - -client = Arcade() # Automatically finds the `ARCADE_API_KEY` env variable - -USER_ID = "{{arcade_user_id}}" -TOOL_NAME = "{tool_fully_qualified_name}" - -auth_response = client.tools.authorize( - tool_name=TOOL_NAME, - user_id=USER_ID, -) - -if auth_response.status != "completed": - print(f"Click this link to authorize: {{auth_response.url}}") - -# Wait for the authorization to complete -client.auth.wait_for_completion(auth_response) - -tool_input = {input_map} - -response = client.tools.execute( - tool_name=TOOL_NAME, - input=tool_input, - user_id=USER_ID, -) -print(json.dumps(response.output.value, indent=2)) -""" - -ENUM_MDX = """## Reference - -Below is a reference of enumerations used by some of the tools in the {toolkit_name} MCP Server: - -{enum_items} -""" - -ENUM_ITEM = """## {enum_name} - -{enum_values} -""" - -ENUM_VALUE = "- **{enum_option_name}**: `{enum_option_value}`" diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py b/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py deleted file mode 100644 index b5d105d2..00000000 --- a/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py +++ /dev/null @@ -1,220 +0,0 @@ -import importlib -import inspect -import os -import re -import sys -from enum import Enum -from pathlib import Path -from types import ModuleType - -if sys.version_info >= (3, 11): - import tomllib -else: - tomllib = None - -from arcade_core.auth import AuthProviderType -from arcade_core.catalog import ToolCatalog -from arcade_core.schema import ToolDefinition, ToolRequirements -from rich.console import Console - -from arcade_cli.utils import discover_toolkits - - -def print_debug_func(debug: bool, console: Console, message: str, style: str = "dim") -> None: - if not debug: - return - console.print(message, style=style) - - -def standardize_dir_path(dir_path: str) -> str: - dir_path = dir_path.rstrip("/") + "/" - return os.path.expanduser(dir_path) - - -def resolve_api_key(cli_input_value: str | None, env_var_name: str) -> str | None: - if cli_input_value: - return cli_input_value - elif os.getenv(env_var_name): - return os.getenv(env_var_name) - else: - return None - - -def write_file(path: str, content: str) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - f.write(content) - - -def read_toolkit_metadata(toolkit_dir: str) -> str: - pyproject_path = os.path.join(toolkit_dir, "pyproject.toml") - - if tomllib is not None: - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - if "project" in data and "name" in data["project"]: - return data["project"]["name"] - else: - # Fallback to regex for Python < 3.11 - with open(pyproject_path) as f: - content = f.read() - project_section_match = re.search(r"\[project\](.*?)(?=\n\[|$)", content, re.DOTALL) - if project_section_match: - project_content = project_section_match.group(1) - name_match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', project_content) - if name_match: - return name_match.group(1).strip() - - raise ValueError(f"Could not find package name in '{pyproject_path}'") - - -def pascal_to_snake_case(text: str) -> str: - return re.sub(r"(? list[ToolDefinition]: - tools = [] - toolkits = discover_toolkits() - - for toolkit in toolkits: - if toolkit.name.casefold() == toolkit_name.casefold(): - for module_name, module_tools in toolkit.tools.items(): - module = importlib.import_module(module_name) - for tool_name in module_tools: - tool_func = getattr(module, tool_name) - tool = ToolCatalog.create_tool_definition( - tool_func, toolkit.name, toolkit.version, toolkit.description - ) - tools.append(tool) - - if not tools: - raise ValueError( - f"Tools not found for the toolkit '{toolkit_name}'. Make sure to have the toolkit " - "installed in your current Python environment." - ) - - return tools - - -def get_all_enumerations(toolkit_root_dir: str) -> dict[str, type[Enum]]: - enums = {} - toolkit_path = Path(toolkit_root_dir) - - for py_file in toolkit_path.rglob("*.py"): - if py_file.name == "__init__.py": - continue - - if ".venv" in py_file.parts or "venv" in py_file.parts: - continue - - module_name = py_file.stem - spec = importlib.util.spec_from_file_location(module_name, py_file) - if spec is None or spec.loader is None: - continue - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - for name, obj in inspect.getmembers(module): - if ( - name not in enums - and inspect.isclass(obj) - and issubclass(obj, Enum) - and obj is not Enum - ): - enums[name] = obj - - return enums - - -def get_toolkit_auth_type(tool_req: ToolRequirements | None) -> str: - if tool_req.authorization: - if tool_req.authorization.provider_type == AuthProviderType.oauth2.value: - return 'authType="OAuth2"' - else: - return f'authType="{tool_req.authorization.provider_type}"' - elif tool_req.secrets: - return 'authType="API Key"' - return 'authType="None"' - - -def find_enum_by_options( - enums: dict[str, type[Enum]], options: list[str] -) -> tuple[str, type[Enum]]: - options_set = set(options) - for enum_name, enum_class in enums.items(): - enum_member_values = [member.value for member in enum_class] - if set(enum_member_values) == options_set: - return enum_name, enum_class - raise ValueError(f"No enum found for options: {options_set}") - - -def is_well_known_provider( - provider_id: str | None, - auth_module: ModuleType, -) -> bool: - if provider_id is None: - return False - - for _, obj in inspect.getmembers(auth_module, inspect.isclass): - if not issubclass(obj, auth_module.OAuth2) or obj is auth_module.OAuth2: - continue - try: - instance = obj() - except AttributeError: - continue - provider_id_matches = ( - hasattr(instance, "provider_id") and instance.provider_id == provider_id - ) - if provider_id_matches: - return True - - return False - - -def clean_fully_qualified_name(fully_qualified_name: str) -> str: - return fully_qualified_name.split("@")[0] - - -def has_wrapper_tools_directory(toolkit_package_path: str) -> bool: - has_dir = os.path.exists(os.path.join(toolkit_package_path, "wrapper_tools")) - if has_dir: - return True - - # Check one level deep - for dir_name in os.listdir(toolkit_package_path): - if os.path.exists(os.path.join(toolkit_package_path, dir_name, "wrapper_tools")): - return True - - return False - - -def find_pyproject_toml(toolkit_package_path: str) -> str: - for root, _, files in os.walk(toolkit_package_path): - for file in files: - if file == "pyproject.toml": - return os.path.join(root, file) - - raise ValueError(f"No pyproject.toml found in {toolkit_package_path}") - - -def get_pyproject_description(pyproject_path: str) -> str: - if tomllib is not None: - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - if "project" in data and "description" in data["project"]: - return data["project"]["description"] - else: - # Fallback to regex for Python < 3.11 - with open(pyproject_path) as f: - content = f.read() - project_section_match = re.search(r"\[project\](.*?)(?=\n\[|$)", content, re.DOTALL) - if project_section_match: - project_content = project_section_match.group(1) - description_match = re.search( - r'description\s*=\s*["\']([^"\']+)["\']', project_content - ) - if description_match: - return description_match.group(1).strip() - - raise ValueError(f"Could not find description in '{pyproject_path}'") diff --git a/libs/tests/cli/toolkit_docs/test_docs_builder.py b/libs/tests/cli/toolkit_docs/test_docs_builder.py deleted file mode 100644 index 1f78182b..00000000 --- a/libs/tests/cli/toolkit_docs/test_docs_builder.py +++ /dev/null @@ -1,57 +0,0 @@ -import json - -from arcade_cli.toolkit_docs.docs_builder import build_javascript_example, build_python_example -from arcade_cli.toolkit_docs.templates import TOOL_CALL_EXAMPLE_JS, TOOL_CALL_EXAMPLE_PY - - -def test_build_javascript_example(): - tool_fully_qualified_name = "Toolkit.ToolName" - - input_map = { - "str_arg": "str_value", - "fake_bool_value": "true", - "fake_bool_phrase": "this is not a true boolean", - "int_arg": 123, - "bool_arg": True, - "list_arg": ["item1", "item2"], - "dict_arg": {"key1": "value1", "key2": "value2"}, - "list_of_bool": [True, False], - } - - response = build_javascript_example(tool_fully_qualified_name, input_map, TOOL_CALL_EXAMPLE_JS) - assert response == TOOL_CALL_EXAMPLE_JS.format( - tool_fully_qualified_name=tool_fully_qualified_name, - input_map=json.dumps(input_map, indent=2, ensure_ascii=False), - ) - - -def test_build_python_example(): - tool_fully_qualified_name = "Toolkit.ToolName" - - input_map = { - "str_arg": "str_value", - "fake_bool_value": "true", - "fake_bool_phrase": "this is not a true boolean", - "int_arg": 123, - "bool_arg": True, - "list_arg": ["item1", "item2"], - "dict_arg": {"key1": "value1", "key2": "value2"}, - "list_of_bool": [True, False], - } - - input_map_str = """{ - 'str_arg': 'str_value', - 'fake_bool_value': 'true', - 'fake_bool_phrase': 'this is not a true boolean', - 'int_arg': 123, - 'bool_arg': True, - 'list_arg': ['item1', 'item2'], - 'dict_arg': {'key1': 'value1', 'key2': 'value2'}, - 'list_of_bool': [True, False] -}""" - - response = build_python_example(tool_fully_qualified_name, input_map, TOOL_CALL_EXAMPLE_PY) - assert response == TOOL_CALL_EXAMPLE_PY.format( - tool_fully_qualified_name=tool_fully_qualified_name, - input_map=input_map_str, - ) diff --git a/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py b/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py deleted file mode 100644 index c04cdb40..00000000 --- a/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py +++ /dev/null @@ -1,175 +0,0 @@ -from types import ModuleType -from unittest.mock import MagicMock, patch - -import pytest -from arcade_cli.toolkit_docs.utils import ( - clean_fully_qualified_name, - get_toolkit_auth_type, - is_well_known_provider, - pascal_to_snake_case, - read_toolkit_metadata, -) -from arcade_core.auth import Asana, AuthProviderType, Google, OAuth2, Slack -from arcade_core.schema import ToolAuthRequirement - - -@patch("arcade_cli.toolkit_docs.utils.open") -@patch("arcade_cli.toolkit_docs.utils.tomllib") -def test_read_toolkit_metadata(mock_tomllib, mock_open): - from unittest.mock import MagicMock, mock_open as mock_open_func - - sample_pyproject_toml = """ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "arcade_jira" -version = "0.1.2" -description = "Arcade.dev LLM tools for interacting with Atlassian Jira" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - -[project.optional-dependencies] -dev = [ - "arcade-mcp[evals]>=2.0.0,<3.0.0", - "arcade-serve>=2.0.0,<3.0.0", - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "pytest-mock>=3.11.1,<3.12.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "tox>=4.11.1,<4.12.0", - "ruff>=0.7.4,<0.8.0", -] - -# Use local path sources for arcade libs when working locally -[tool.uv.sources] -arcade-mcp = {path = "../../", editable = true} -arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } -arcade-serve = { path = "../../libs/arcade-serve/", editable = true } - -[tool.mypy] -files = [ "arcade_jira/**/*.py",] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = [ "tests",] - -[tool.coverage.report] -skip_empty = true - -[tool.hatch.build.targets.wheel] -packages = [ "arcade_jira",] - """ - - # Setup mock to handle both binary and text mode - def open_side_effect(path, mode="r"): - if mode == "rb": - return mock_open_func(read_data=sample_pyproject_toml.encode()).return_value - else: - return mock_open_func(read_data=sample_pyproject_toml).return_value - - mock_open.side_effect = open_side_effect - mock_tomllib.load.return_value = {"project": {"name": "arcade_jira"}} - - assert read_toolkit_metadata("path/to/toolkits/jira") == "arcade_jira" - - -@patch("arcade_cli.toolkit_docs.utils.open") -@patch("arcade_cli.toolkit_docs.utils.tomllib") -def test_read_toolkit_metadata_missing_project_name(mock_tomllib, mock_open): - from unittest.mock import mock_open as mock_open_func - - sample_pyproject_toml = """ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -version = "0.1.2" -description = "Arcade.dev LLM tools for interacting with Atlassian Jira" -requires-python = ">=3.10" -dependencies = [ - "arcade-tdk>=2.0.0,<3.0.0", - "httpx>=0.27.2,<1.0.0", -] -[[project.authors]] -name = "Arcade" -email = "dev@arcade.dev" - """ - - # Setup mock to handle both binary and text mode - def open_side_effect(path, mode="r"): - if mode == "rb": - return mock_open_func(read_data=sample_pyproject_toml.encode()).return_value - else: - return mock_open_func(read_data=sample_pyproject_toml).return_value - - mock_open.side_effect = open_side_effect - mock_tomllib.load.return_value = {"project": {}} # Missing "name" - - with pytest.raises(ValueError): - read_toolkit_metadata("path/to/toolkits/jira") - - -def test_pascal_to_snake_case(): - assert pascal_to_snake_case("PascalCase") == "pascal_case" - assert pascal_to_snake_case("PascalCase_abc") == "pascal_case_abc" - - -def test_get_toolkit_auth_type_none(): - from arcade_core.schema import ToolRequirements - - tool_req = ToolRequirements() - assert get_toolkit_auth_type(tool_req=tool_req) == 'authType="None"' - - -def test_get_toolkit_auth_type_with_provider_type(): - from arcade_core.schema import ToolRequirements, ToolSecretRequirement - - tool_req = ToolRequirements(authorization=ToolAuthRequirement(provider_type=AuthProviderType.oauth2.value)) - assert get_toolkit_auth_type(tool_req=tool_req) == 'authType="OAuth2"' - - tool_req = ToolRequirements(authorization=ToolAuthRequirement(provider_type="another_type")) - assert get_toolkit_auth_type(tool_req=tool_req) == 'authType="another_type"' - - tool_req = ToolRequirements(secrets=[ToolSecretRequirement(key="API_KEY")]) - assert get_toolkit_auth_type(tool_req=tool_req) == 'authType="API Key"' - - -def test_is_well_known_provider_none(): - assert not is_well_known_provider(provider_id=None, auth_module=MagicMock(spec=ModuleType)) - - -def test_is_well_known_provider_matching_provider_id(): - mock_auth_module = MagicMock(spec=ModuleType) - - mock_auth_module.OAuth2 = OAuth2 - mock_auth_module.Google = Google - mock_auth_module.Slack = Slack - - assert is_well_known_provider(provider_id=Google().provider_id, auth_module=mock_auth_module) - assert is_well_known_provider(provider_id=Slack().provider_id, auth_module=mock_auth_module) - assert not is_well_known_provider(provider_id=Asana().provider_id, auth_module=mock_auth_module) - assert not is_well_known_provider(provider_id="another_provider", auth_module=mock_auth_module) - - -def test_clean_fully_qualified_name(): - assert clean_fully_qualified_name("Outlook.ListEmails") == "Outlook.ListEmails" - assert clean_fully_qualified_name("Outlook.ListEmails@1.0.0") == "Outlook.ListEmails"