Remove arcade docs CLI command - moved to the 'ArcadeAI/docs' repo (#642)
This commit is contained in:
parent
ba1b2e6788
commit
4dfd0522a6
7 changed files with 0 additions and 1418 deletions
|
|
@ -27,7 +27,6 @@ from arcade_cli.display import (
|
||||||
display_eval_results,
|
display_eval_results,
|
||||||
)
|
)
|
||||||
from arcade_cli.show import show_logic
|
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.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
Provider,
|
Provider,
|
||||||
|
|
@ -707,115 +706,6 @@ def dashboard(
|
||||||
handle_cli_error("Failed to open dashboard", e, debug)
|
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()
|
@cli.callback()
|
||||||
def main_callback(
|
def main_callback(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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 = '<StarterToolInfo toolkitName="{toolkit_name}" />'
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
<ToolInfo
|
|
||||||
description="{tool_info_description}"
|
|
||||||
author="Arcade"
|
|
||||||
{auth_type}
|
|
||||||
versions={{["{version}"]}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Badges repo="arcadeai/{pip_package_name}" />
|
|
||||||
|
|
||||||
{starter_tool_info_warning}
|
|
||||||
|
|
||||||
{description}"""
|
|
||||||
|
|
||||||
TABLE_OF_CONTENTS = """## Available Tools
|
|
||||||
|
|
||||||
<TableOfContents
|
|
||||||
headers={{["Tool Name", "Description"]}}
|
|
||||||
data={{
|
|
||||||
[{tool_items}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Callout>
|
|
||||||
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).
|
|
||||||
</Callout>"""
|
|
||||||
|
|
||||||
TABLE_OF_CONTENTS_ITEM = '\n ["{tool_fully_qualified_name}", "{description}"],'
|
|
||||||
|
|
||||||
TOOL_SPEC = """## {tool_fully_qualified_name}
|
|
||||||
|
|
||||||
<br />
|
|
||||||
{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 = """<TabbedCodeBlock
|
|
||||||
tabs={{[
|
|
||||||
{{
|
|
||||||
label: "Call the Tool Directly",
|
|
||||||
content: {{
|
|
||||||
Python: ["/examples/integrations/mcp-servers/{toolkit_name}/{tool_name}_example_call_tool.py"],
|
|
||||||
JavaScript: ["/examples/integrations/mcp-servers/{toolkit_name}/{tool_name}_example_call_tool.js"],
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
]}}
|
|
||||||
/>"""
|
|
||||||
|
|
||||||
TOOL_PARAMETER = "- **{param_name}** ({definition}) {description}"
|
|
||||||
|
|
||||||
TOOLKIT_FOOTER = """<ToolFooter pipPackageName="{pip_package_name}" />"""
|
|
||||||
|
|
||||||
TOOLKIT_FOOTER_OAUTH2 = """## Auth
|
|
||||||
|
|
||||||
{provider_configuration}
|
|
||||||
|
|
||||||
<ToolFooter pipPackageName="{pip_package_name}" />
|
|
||||||
"""
|
|
||||||
|
|
||||||
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}`"
|
|
||||||
|
|
@ -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"(?<!^)(?=[A-Z])", "_", text).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def get_list_of_tools(toolkit_name: str) -> 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}'")
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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"
|
|
||||||
Loading…
Reference in a new issue