diff --git a/examples/ai-sdk/.env.example b/examples/agent_frameworks/ai-sdk/.env.example similarity index 100% rename from examples/ai-sdk/.env.example rename to examples/agent_frameworks/ai-sdk/.env.example diff --git a/examples/ai-sdk/.gitignore b/examples/agent_frameworks/ai-sdk/.gitignore similarity index 100% rename from examples/ai-sdk/.gitignore rename to examples/agent_frameworks/ai-sdk/.gitignore diff --git a/examples/ai-sdk/LICENSE b/examples/agent_frameworks/ai-sdk/LICENSE similarity index 100% rename from examples/ai-sdk/LICENSE rename to examples/agent_frameworks/ai-sdk/LICENSE diff --git a/examples/ai-sdk/README.md b/examples/agent_frameworks/ai-sdk/README.md similarity index 100% rename from examples/ai-sdk/README.md rename to examples/agent_frameworks/ai-sdk/README.md diff --git a/examples/ai-sdk/generateText.js b/examples/agent_frameworks/ai-sdk/generateText.js similarity index 100% rename from examples/ai-sdk/generateText.js rename to examples/agent_frameworks/ai-sdk/generateText.js diff --git a/examples/ai-sdk/index.js b/examples/agent_frameworks/ai-sdk/index.js similarity index 100% rename from examples/ai-sdk/index.js rename to examples/agent_frameworks/ai-sdk/index.js diff --git a/examples/ai-sdk/package.json b/examples/agent_frameworks/ai-sdk/package.json similarity index 100% rename from examples/ai-sdk/package.json rename to examples/agent_frameworks/ai-sdk/package.json diff --git a/examples/ai-sdk/pnpm-lock.yaml b/examples/agent_frameworks/ai-sdk/pnpm-lock.yaml similarity index 100% rename from examples/ai-sdk/pnpm-lock.yaml rename to examples/agent_frameworks/ai-sdk/pnpm-lock.yaml diff --git a/examples/crewai/crewai_with_arcade_tool.py b/examples/agent_frameworks/crewai/crewai_with_arcade_tool.py similarity index 100% rename from examples/crewai/crewai_with_arcade_tool.py rename to examples/agent_frameworks/crewai/crewai_with_arcade_tool.py diff --git a/examples/crewai/requirements.txt b/examples/agent_frameworks/crewai/requirements.txt similarity index 100% rename from examples/crewai/requirements.txt rename to examples/agent_frameworks/crewai/requirements.txt diff --git a/examples/crewai/simple_crewai_with_arcade_tool.py b/examples/agent_frameworks/crewai/simple_crewai_with_arcade_tool.py similarity index 100% rename from examples/crewai/simple_crewai_with_arcade_tool.py rename to examples/agent_frameworks/crewai/simple_crewai_with_arcade_tool.py diff --git a/examples/langchain-ts/.env.example b/examples/agent_frameworks/langchain-ts/.env.example similarity index 100% rename from examples/langchain-ts/.env.example rename to examples/agent_frameworks/langchain-ts/.env.example diff --git a/examples/langchain-ts/.gitignore b/examples/agent_frameworks/langchain-ts/.gitignore similarity index 100% rename from examples/langchain-ts/.gitignore rename to examples/agent_frameworks/langchain-ts/.gitignore diff --git a/examples/langchain-ts/LICENSE b/examples/agent_frameworks/langchain-ts/LICENSE similarity index 100% rename from examples/langchain-ts/LICENSE rename to examples/agent_frameworks/langchain-ts/LICENSE diff --git a/examples/langchain-ts/langchain-tool-arcade-auth.ts b/examples/agent_frameworks/langchain-ts/langchain-tool-arcade-auth.ts similarity index 100% rename from examples/langchain-ts/langchain-tool-arcade-auth.ts rename to examples/agent_frameworks/langchain-ts/langchain-tool-arcade-auth.ts diff --git a/examples/langchain-ts/langgraph-arcade-minimal.ts b/examples/agent_frameworks/langchain-ts/langgraph-arcade-minimal.ts similarity index 100% rename from examples/langchain-ts/langgraph-arcade-minimal.ts rename to examples/agent_frameworks/langchain-ts/langgraph-arcade-minimal.ts diff --git a/examples/langchain-ts/langgraph-with-user-auth.ts b/examples/agent_frameworks/langchain-ts/langgraph-with-user-auth.ts similarity index 100% rename from examples/langchain-ts/langgraph-with-user-auth.ts rename to examples/agent_frameworks/langchain-ts/langgraph-with-user-auth.ts diff --git a/examples/langchain-ts/langgraph.json b/examples/agent_frameworks/langchain-ts/langgraph.json similarity index 100% rename from examples/langchain-ts/langgraph.json rename to examples/agent_frameworks/langchain-ts/langgraph.json diff --git a/examples/langchain-ts/package-lock.json b/examples/agent_frameworks/langchain-ts/package-lock.json similarity index 100% rename from examples/langchain-ts/package-lock.json rename to examples/agent_frameworks/langchain-ts/package-lock.json diff --git a/examples/langchain-ts/package.json b/examples/agent_frameworks/langchain-ts/package.json similarity index 100% rename from examples/langchain-ts/package.json rename to examples/agent_frameworks/langchain-ts/package.json diff --git a/examples/langchain-ts/pnpm-lock.yaml b/examples/agent_frameworks/langchain-ts/pnpm-lock.yaml similarity index 100% rename from examples/langchain-ts/pnpm-lock.yaml rename to examples/agent_frameworks/langchain-ts/pnpm-lock.yaml diff --git a/examples/langchain-ts/tsconfig.json b/examples/agent_frameworks/langchain-ts/tsconfig.json similarity index 100% rename from examples/langchain-ts/tsconfig.json rename to examples/agent_frameworks/langchain-ts/tsconfig.json diff --git a/examples/langchain/langchain_tool_arcade_auth.py b/examples/agent_frameworks/langchain/langchain_tool_arcade_auth.py similarity index 100% rename from examples/langchain/langchain_tool_arcade_auth.py rename to examples/agent_frameworks/langchain/langchain_tool_arcade_auth.py diff --git a/examples/langchain/langgraph_arcade_minimal.py b/examples/agent_frameworks/langchain/langgraph_arcade_minimal.py similarity index 100% rename from examples/langchain/langgraph_arcade_minimal.py rename to examples/agent_frameworks/langchain/langgraph_arcade_minimal.py diff --git a/examples/langchain/langgraph_with_user_auth.py b/examples/agent_frameworks/langchain/langgraph_with_user_auth.py similarity index 100% rename from examples/langchain/langgraph_with_user_auth.py rename to examples/agent_frameworks/langchain/langgraph_with_user_auth.py diff --git a/examples/langchain/requirements.txt b/examples/agent_frameworks/langchain/requirements.txt similarity index 100% rename from examples/langchain/requirements.txt rename to examples/agent_frameworks/langchain/requirements.txt diff --git a/examples/langchain/studio/README.md b/examples/agent_frameworks/langchain/studio/README.md similarity index 100% rename from examples/langchain/studio/README.md rename to examples/agent_frameworks/langchain/studio/README.md diff --git a/examples/langchain/studio/configuration.py b/examples/agent_frameworks/langchain/studio/configuration.py similarity index 100% rename from examples/langchain/studio/configuration.py rename to examples/agent_frameworks/langchain/studio/configuration.py diff --git a/examples/langchain/studio/env.example b/examples/agent_frameworks/langchain/studio/env.example similarity index 100% rename from examples/langchain/studio/env.example rename to examples/agent_frameworks/langchain/studio/env.example diff --git a/examples/langchain/studio/graph.py b/examples/agent_frameworks/langchain/studio/graph.py similarity index 100% rename from examples/langchain/studio/graph.py rename to examples/agent_frameworks/langchain/studio/graph.py diff --git a/examples/langchain/studio/langgraph.json b/examples/agent_frameworks/langchain/studio/langgraph.json similarity index 100% rename from examples/langchain/studio/langgraph.json rename to examples/agent_frameworks/langchain/studio/langgraph.json diff --git a/examples/langchain/studio/requirements.txt b/examples/agent_frameworks/langchain/studio/requirements.txt similarity index 100% rename from examples/langchain/studio/requirements.txt rename to examples/agent_frameworks/langchain/studio/requirements.txt diff --git a/examples/langgraph-ts/.gitignore b/examples/agent_frameworks/langgraph-ts/.gitignore similarity index 100% rename from examples/langgraph-ts/.gitignore rename to examples/agent_frameworks/langgraph-ts/.gitignore diff --git a/examples/langgraph-ts/README.md b/examples/agent_frameworks/langgraph-ts/README.md similarity index 100% rename from examples/langgraph-ts/README.md rename to examples/agent_frameworks/langgraph-ts/README.md diff --git a/examples/langgraph-ts/langgraph.json b/examples/agent_frameworks/langgraph-ts/langgraph.json similarity index 100% rename from examples/langgraph-ts/langgraph.json rename to examples/agent_frameworks/langgraph-ts/langgraph.json diff --git a/examples/langgraph-ts/package.json b/examples/agent_frameworks/langgraph-ts/package.json similarity index 100% rename from examples/langgraph-ts/package.json rename to examples/agent_frameworks/langgraph-ts/package.json diff --git a/examples/langgraph-ts/src/configuration.ts b/examples/agent_frameworks/langgraph-ts/src/configuration.ts similarity index 100% rename from examples/langgraph-ts/src/configuration.ts rename to examples/agent_frameworks/langgraph-ts/src/configuration.ts diff --git a/examples/langgraph-ts/src/graph.ts b/examples/agent_frameworks/langgraph-ts/src/graph.ts similarity index 100% rename from examples/langgraph-ts/src/graph.ts rename to examples/agent_frameworks/langgraph-ts/src/graph.ts diff --git a/examples/langgraph-ts/src/prompts.ts b/examples/agent_frameworks/langgraph-ts/src/prompts.ts similarity index 100% rename from examples/langgraph-ts/src/prompts.ts rename to examples/agent_frameworks/langgraph-ts/src/prompts.ts diff --git a/examples/langgraph-ts/studio.png b/examples/agent_frameworks/langgraph-ts/studio.png similarity index 100% rename from examples/langgraph-ts/studio.png rename to examples/agent_frameworks/langgraph-ts/studio.png diff --git a/examples/mastra/.env.example b/examples/agent_frameworks/mastra/.env.example similarity index 100% rename from examples/mastra/.env.example rename to examples/agent_frameworks/mastra/.env.example diff --git a/examples/mastra/.gitignore b/examples/agent_frameworks/mastra/.gitignore similarity index 100% rename from examples/mastra/.gitignore rename to examples/agent_frameworks/mastra/.gitignore diff --git a/examples/mastra/LICENSE b/examples/agent_frameworks/mastra/LICENSE similarity index 100% rename from examples/mastra/LICENSE rename to examples/agent_frameworks/mastra/LICENSE diff --git a/examples/mastra/README.md b/examples/agent_frameworks/mastra/README.md similarity index 100% rename from examples/mastra/README.md rename to examples/agent_frameworks/mastra/README.md diff --git a/examples/mastra/package.json b/examples/agent_frameworks/mastra/package.json similarity index 100% rename from examples/mastra/package.json rename to examples/agent_frameworks/mastra/package.json diff --git a/examples/mastra/pnpm-lock.yaml b/examples/agent_frameworks/mastra/pnpm-lock.yaml similarity index 100% rename from examples/mastra/pnpm-lock.yaml rename to examples/agent_frameworks/mastra/pnpm-lock.yaml diff --git a/examples/mastra/src/mastra/agents/gmail.ts b/examples/agent_frameworks/mastra/src/mastra/agents/gmail.ts similarity index 100% rename from examples/mastra/src/mastra/agents/gmail.ts rename to examples/agent_frameworks/mastra/src/mastra/agents/gmail.ts diff --git a/examples/mastra/src/mastra/agents/inboxTravelSearch.ts b/examples/agent_frameworks/mastra/src/mastra/agents/inboxTravelSearch.ts similarity index 100% rename from examples/mastra/src/mastra/agents/inboxTravelSearch.ts rename to examples/agent_frameworks/mastra/src/mastra/agents/inboxTravelSearch.ts diff --git a/examples/mastra/src/mastra/index.ts b/examples/agent_frameworks/mastra/src/mastra/index.ts similarity index 100% rename from examples/mastra/src/mastra/index.ts rename to examples/agent_frameworks/mastra/src/mastra/index.ts diff --git a/examples/mastra/src/mastra/tools/flightSearchTools.ts b/examples/agent_frameworks/mastra/src/mastra/tools/flightSearchTools.ts similarity index 100% rename from examples/mastra/src/mastra/tools/flightSearchTools.ts rename to examples/agent_frameworks/mastra/src/mastra/tools/flightSearchTools.ts diff --git a/examples/mastra/src/mastra/tools/gmailTools.ts b/examples/agent_frameworks/mastra/src/mastra/tools/gmailTools.ts similarity index 100% rename from examples/mastra/src/mastra/tools/gmailTools.ts rename to examples/agent_frameworks/mastra/src/mastra/tools/gmailTools.ts diff --git a/examples/mastra/src/mastra/tools/hotelSearchTools.ts b/examples/agent_frameworks/mastra/src/mastra/tools/hotelSearchTools.ts similarity index 100% rename from examples/mastra/src/mastra/tools/hotelSearchTools.ts rename to examples/agent_frameworks/mastra/src/mastra/tools/hotelSearchTools.ts diff --git a/examples/mastra/tsconfig.json b/examples/agent_frameworks/mastra/tsconfig.json similarity index 100% rename from examples/mastra/tsconfig.json rename to examples/agent_frameworks/mastra/tsconfig.json diff --git a/examples/openai-agents-ts/.env.example b/examples/agent_frameworks/openai-agents-ts/.env.example similarity index 100% rename from examples/openai-agents-ts/.env.example rename to examples/agent_frameworks/openai-agents-ts/.env.example diff --git a/examples/openai-agents-ts/.gitignore b/examples/agent_frameworks/openai-agents-ts/.gitignore similarity index 100% rename from examples/openai-agents-ts/.gitignore rename to examples/agent_frameworks/openai-agents-ts/.gitignore diff --git a/examples/openai-agents-ts/LICENSE b/examples/agent_frameworks/openai-agents-ts/LICENSE similarity index 100% rename from examples/openai-agents-ts/LICENSE rename to examples/agent_frameworks/openai-agents-ts/LICENSE diff --git a/examples/openai-agents-ts/README.md b/examples/agent_frameworks/openai-agents-ts/README.md similarity index 100% rename from examples/openai-agents-ts/README.md rename to examples/agent_frameworks/openai-agents-ts/README.md diff --git a/examples/openai-agents-ts/package-lock.json b/examples/agent_frameworks/openai-agents-ts/package-lock.json similarity index 100% rename from examples/openai-agents-ts/package-lock.json rename to examples/agent_frameworks/openai-agents-ts/package-lock.json diff --git a/examples/openai-agents-ts/package.json b/examples/agent_frameworks/openai-agents-ts/package.json similarity index 100% rename from examples/openai-agents-ts/package.json rename to examples/agent_frameworks/openai-agents-ts/package.json diff --git a/examples/openai-agents-ts/src/index.ts b/examples/agent_frameworks/openai-agents-ts/src/index.ts similarity index 100% rename from examples/openai-agents-ts/src/index.ts rename to examples/agent_frameworks/openai-agents-ts/src/index.ts diff --git a/examples/openai-agents-ts/src/waitForCompletion.ts b/examples/agent_frameworks/openai-agents-ts/src/waitForCompletion.ts similarity index 100% rename from examples/openai-agents-ts/src/waitForCompletion.ts rename to examples/agent_frameworks/openai-agents-ts/src/waitForCompletion.ts diff --git a/examples/call_a_tool_directly.py b/examples/clients/call_a_tool_directly.py similarity index 100% rename from examples/call_a_tool_directly.py rename to examples/clients/call_a_tool_directly.py diff --git a/examples/call_a_tool_directly_with_auth.py b/examples/clients/call_a_tool_directly_with_auth.py similarity index 100% rename from examples/call_a_tool_directly_with_auth.py rename to examples/clients/call_a_tool_directly_with_auth.py diff --git a/examples/call_a_tool_with_llm.py b/examples/clients/call_a_tool_with_llm.py similarity index 100% rename from examples/call_a_tool_with_llm.py rename to examples/clients/call_a_tool_with_llm.py diff --git a/examples/get_auth_token.py b/examples/clients/get_auth_token.py similarity index 100% rename from examples/get_auth_token.py rename to examples/clients/get_auth_token.py diff --git a/examples/mcp_servers/local_filesystem_server/pyproject.toml b/examples/mcp_servers/local_filesystem_server/pyproject.toml new file mode 100644 index 00000000..539acfe3 --- /dev/null +++ b/examples/mcp_servers/local_filesystem_server/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "local_filesystem" +version = "0.1.0" +description = "MCP Server created with Arcade.dev" +requires-python = ">=3.10" +dependencies = [ + "arcade-mcp-server>=1.0.1,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "arcade-mcp[all]>=1.0.1,<2.0.0", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +# Tell Arcade.dev that this package has Arcade tools +[project.entry-points.arcade_toolkits] +toolkit_name = "local_filesystem" + +[tool.setuptools.packages.find] +include = ["local_filesystem*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +warn_unused_configs = true +disallow_untyped_defs = false diff --git a/examples/mcp_servers/local_filesystem_server/server.py b/examples/mcp_servers/local_filesystem_server/server.py new file mode 100644 index 00000000..43320d9e --- /dev/null +++ b/examples/mcp_servers/local_filesystem_server/server.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""local_filesystem MCP server""" + +import sys + +from arcade_mcp_server import MCPApp + +from tools import ( + copy_path, + create_directory, + list_directory, + move_path, + read_file, + search_files, + stat_path, + tail_file, + write_file, +) + +app = MCPApp(name="local_filesystem", version="1.0.0", log_level="DEBUG") + + +app.add_tool(list_directory) +app.add_tool(read_file) +app.add_tool(write_file) +app.add_tool(tail_file) +app.add_tool(stat_path) +app.add_tool(create_directory) +app.add_tool(move_path) +app.add_tool(copy_path) +app.add_tool(search_files) + +# Run with specific transport +if __name__ == "__main__": + # Get transport from command line argument, default to "http" + transport = sys.argv[1] if len(sys.argv) > 1 else "http" + + # Run the server + # - "http" (default): HTTPS streaming for Cursor, VS Code, etc. + # - "stdio": Standard I/O for Claude Desktop, CLI tools, etc. + app.run(transport=transport, host="127.0.0.1", port=8000) diff --git a/examples/mcp_servers/local_filesystem_server/tools.py b/examples/mcp_servers/local_filesystem_server/tools.py new file mode 100644 index 00000000..3dfb0460 --- /dev/null +++ b/examples/mcp_servers/local_filesystem_server/tools.py @@ -0,0 +1,449 @@ +import base64 +import fnmatch +import os +import re +import shutil +import stat +from datetime import datetime +from pathlib import Path +from typing import Annotated + +from arcade_mcp_server import tool + + +def _split_globs(globs: str) -> list[str]: + patterns = [p.strip() for p in globs.split(",") if p.strip()] + return patterns + + +def _is_hidden(path: Path) -> bool: + return path.name.startswith(".") + + +def _match_any(name: str, patterns: list[str]) -> bool: + if not patterns: + return True + return any(fnmatch.fnmatch(name, pattern) for pattern in patterns) + + +def _match_none(name: str, patterns: list[str]) -> bool: + return all(not fnmatch.fnmatch(name, pattern) for pattern in patterns) + + +def _path_type(path: Path, follow_symlinks: bool) -> str: + try: + st = path.stat() if follow_symlinks else path.lstat() + mode = st.st_mode + if stat.S_ISDIR(mode): + return "dir" + if stat.S_ISLNK(mode): + return "symlink" + return "file" # noqa: TRY300 + except FileNotFoundError: + return "unknown" + + +@tool +def list_directory( + path: Annotated[str, "Directory to list"], + recursive: Annotated[bool, "Recurse into subdirectories"] = False, + max_depth: Annotated[int, "Maximum recursion depth (1 = just this dir)"] = 1, + include_globs: Annotated[str, "Comma-separated include patterns (e.g. *.py,*.md)"] = "", + exclude_globs: Annotated[str, "Comma-separated exclude patterns"] = "", + show_hidden: Annotated[bool, "Include dotfiles"] = False, + follow_symlinks: Annotated[bool, "Follow symlinks during traversal"] = False, + max_entries: Annotated[int, "Maximum number of entries to return"] = 1000, +) -> list[dict]: + """Enumerate files and folders with metadata.""" + root = Path(path).expanduser().resolve() + includes = _split_globs(include_globs) + excludes = _split_globs(exclude_globs) + + results: list[dict] = [] + entries_count = 0 + + def add_entry(p: Path) -> None: + nonlocal entries_count + try: + st = p.stat() if follow_symlinks else p.lstat() + except FileNotFoundError: + return + entry_type = _path_type(p, follow_symlinks) + if not show_hidden and _is_hidden(p): + return + name = p.name + if not _match_any(name, includes) or not _match_none(name, excludes): + return + results.append( + { + "path": str(p), + "name": name, + "type": entry_type, + "size": int(st.st_size), + "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), + "mode": int(st.st_mode), + "is_symlink": p.is_symlink(), + } + ) + entries_count += 1 + + if not root.exists() or not root.is_dir(): + return [] + + if not recursive: + for child in root.iterdir(): + if entries_count >= max_entries: + break + add_entry(child) + return results + + # Recursive traversal with depth control + start_depth = len(root.parts) + for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks): + current = Path(dirpath) + depth = len(current.parts) - start_depth + 1 + if depth > max_depth: + # Prevent descent by clearing dirnames + dirnames[:] = [] + continue + + # Optionally filter hidden directories early + if not show_hidden: + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + + for name in dirnames + filenames: + if entries_count >= max_entries: + break + add_entry(current / name) + if entries_count >= max_entries: + break + + return results + + +@tool +def read_file( + path: Annotated[str, "Path to file"], + binary: Annotated[bool, "Return base64 when true"] = False, + encoding: Annotated[str, "Text encoding when binary is false"] = "utf-8", + start: Annotated[int, "Start byte offset (0-based)"] = 0, + end: Annotated[int, "End byte offset (0 for EOF)"] = 0, + max_bytes: Annotated[int, "Maximum bytes to read (0 for unlimited)"] = 1048576, +) -> dict: + """Read file contents safely with optional slicing.""" + p = Path(path).expanduser().resolve() + if not p.exists() or not p.is_file(): + return {"content": "", "bytes_read": 0, "encoding": encoding, "truncated": False} + + total_size = p.stat().st_size + start_offset = max(0, start) + end_offset = total_size if end <= 0 else min(end, total_size) + if end_offset < start_offset: + end_offset = start_offset + to_read = end_offset - start_offset + if max_bytes > 0: + to_read = min(to_read, max_bytes) + + with open(p, "rb") as f: + f.seek(start_offset) + data = f.read(to_read) + + truncated = False + if (end <= 0 and max_bytes > 0 and (start_offset + len(data)) < total_size) or ( + end > 0 and len(data) < (end_offset - start_offset) + ): + truncated = True + + if binary: + return { + "content": base64.b64encode(data).decode("ascii"), + "bytes_read": len(data), + "encoding": "base64", + "truncated": truncated, + } + else: + text = data.decode(encoding, errors="replace") + return { + "content": text, + "bytes_read": len(data), + "encoding": encoding, + "truncated": truncated, + } + + +@tool +def write_file( + path: Annotated[str, "Path to write"], + content: Annotated[str, "Text or base64 when binary=true"], + binary: Annotated[bool, "Interpret content as base64 when true"] = False, + encoding: Annotated[str, "Text encoding when binary is false"] = "utf-8", + mode: Annotated[str, "overwrite | append | create_new"] = "overwrite", + make_parents: Annotated[bool, "Create parent directories if needed"] = True, +) -> dict: + """Create/overwrite/append text or binary file.""" + p = Path(path).expanduser().resolve() + if make_parents: + p.parent.mkdir(parents=True, exist_ok=True) + + data = base64.b64decode(content) if binary else content.encode(encoding) + + created = False + appended = False + + if mode == "create_new": + if p.exists(): + raise FileExistsError(f"Path already exists: {p}") + with open(p, "wb") as f: + f.write(data) + created = True + elif mode == "append": + with open(p, "ab") as f: + f.write(data) + appended = True + created = not (p.stat().st_size > len(data)) # heuristic + elif mode == "overwrite": + with open(p, "wb") as f: + f.write(data) + created = True + else: + raise ValueError("Invalid mode. Use overwrite | append | create_new") + + return {"bytes_written": len(data), "created": created, "appended": appended} + + +@tool +def tail_file( + path: Annotated[str, "Path to file"], + lines: Annotated[int, "Number of lines from end (ignored if bytes>0)"] = 100, + bytes: Annotated[int, "Number of bytes from end (0 to disable)"] = 0, + encoding: Annotated[str, "Text encoding for output"] = "utf-8", +) -> dict: + """Return last N lines or bytes from a file.""" + p = Path(path).expanduser().resolve() + if not p.exists() or not p.is_file(): + return {"content": "", "truncated": False} + + size = p.stat().st_size + if bytes > 0: + start = max(0, size - bytes) + with open(p, "rb") as f: + f.seek(start) + data = f.read(bytes) + text = data.decode(encoding, errors="replace") + return {"content": text, "truncated": start > 0} + + # Tail by lines + block_size = 8192 + data = bytearray() + newline_count = 0 + with open(p, "rb") as f: + pos = size + while pos > 0 and newline_count <= lines: + read_size = block_size if pos >= block_size else pos + pos -= read_size + f.seek(pos) + chunk = f.read(read_size) + data[:0] = chunk + newline_count = data.count(b"\n") + if len(data) > 8 * 1024 * 1024: # 8MB safety cap + break + decoded = data.decode(encoding, errors="replace") + split_lines = decoded.splitlines()[-lines:] + return {"content": "\n".join(split_lines), "truncated": len(split_lines) < lines} + + +@tool +def stat_path( + path: Annotated[str, "Path to stat"], + follow_symlinks: Annotated[bool, "Follow symlinks"] = False, +) -> dict: + """Return metadata for file/dir/symlink.""" + p = Path(path).expanduser().resolve() + exists = p.exists() or p.is_symlink() + try: + st = p.stat() if follow_symlinks else p.lstat() + except FileNotFoundError: + return {"exists": False} + info = { + "exists": exists, + "path": str(p), + "type": _path_type(p, follow_symlinks), + "size": int(st.st_size), + "mode": int(st.st_mode), + "uid": int(getattr(st, "st_uid", 0)), + "gid": int(getattr(st, "st_gid", 0)), + "atime": datetime.fromtimestamp(st.st_atime).isoformat(), + "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), + "ctime": datetime.fromtimestamp(st.st_ctime).isoformat(), + "is_symlink": p.is_symlink(), + } + if p.is_symlink(): + try: + info["target"] = str(p.resolve(strict=False)) + except Exception: + info["target"] = None + return info + + +@tool +def create_directory( + path: Annotated[str, "Directory path to create"], + exist_ok: Annotated[bool, "Do not error if directory exists"] = True, + parents: Annotated[bool, "Create missing parents"] = True, + mode: Annotated[int, "POSIX permissions like 0o755"] = 0o755, +) -> dict: + """Create a directory path.""" + p = Path(path).expanduser().resolve() + if parents: + p.mkdir(mode=mode, parents=True, exist_ok=exist_ok) + else: + if p.exists() and not exist_ok: + raise FileExistsError(f"Directory exists: {p}") + p.mkdir(mode=mode) + return {"created": True} + + +@tool +def move_path( + src: Annotated[str, "Source path"], + dst: Annotated[str, "Destination path"], + overwrite: Annotated[bool, "Replace destination if exists"] = False, + make_parents: Annotated[bool, "Create destination parents"] = True, +) -> dict: + """Move/rename files or directories.""" + src_p = Path(src).expanduser().resolve() + dst_p = Path(dst).expanduser().resolve() + if make_parents: + dst_p.parent.mkdir(parents=True, exist_ok=True) + + if dst_p.exists(): + if overwrite: + # Atomic replace for files; for dirs, move into or replace + if src_p.is_file(): + os.replace(src_p, dst_p) + else: + # Move into destination if it's an existing directory + if dst_p.is_dir(): + shutil.move(str(src_p), str(dst_p)) + else: + os.replace(src_p, dst_p) + else: + raise FileExistsError(f"Destination exists: {dst_p}") + else: + shutil.move(str(src_p), str(dst_p)) + return {"moved": True} + + +@tool +def copy_path( + src: Annotated[str, "Source path"], + dst: Annotated[str, "Destination path"], + overwrite: Annotated[bool, "Replace destination if exists"] = False, + preserve_metadata: Annotated[bool, "Preserve file metadata"] = True, + follow_symlinks: Annotated[bool, "Follow symlinks when copying files"] = False, + make_parents: Annotated[bool, "Create destination parents"] = True, +) -> dict: + """Copy file or directory tree.""" + src_p = Path(src).expanduser().resolve() + dst_p = Path(dst).expanduser().resolve() + if make_parents: + dst_p.parent.mkdir(parents=True, exist_ok=True) + + if src_p.is_dir(): + shutil.copytree( + src=str(src_p), + dst=str(dst_p), + dirs_exist_ok=overwrite, + copy_function=shutil.copy2 if preserve_metadata else shutil.copy, + ) + else: + if dst_p.exists() and not overwrite: + raise FileExistsError(f"Destination exists: {dst_p}") + copy_fn = shutil.copy2 if preserve_metadata else shutil.copy + copy_fn(str(src_p), str(dst_p), follow_symlinks=follow_symlinks) + return {"copied": True} + + +@tool +def search_files( + root: Annotated[str, "Root directory to search"], + name_globs: Annotated[str, "Comma-separated include name patterns"] = "", + exclude_globs: Annotated[str, "Comma-separated exclude name patterns"] = "", + regex: Annotated[str, "Content regex (ignored when fixed_string=true)"] = "", + fixed_string: Annotated[bool, "Search content by plain substring"] = False, + case_insensitive: Annotated[bool, "Regex or substring is case-insensitive"] = False, + max_results: Annotated[int, "Maximum number of results"] = 100, + follow_symlinks: Annotated[bool, "Follow symlinks"] = False, + binary: Annotated[bool, "Search binary files too"] = False, + include_hidden: Annotated[bool, "Include hidden files"] = False, +) -> list[dict]: + """Find files by name and optionally by content.""" + base = Path(root).expanduser().resolve() + if not base.exists() or not base.is_dir(): + return [] + + includes = _split_globs(name_globs) + excludes = _split_globs(exclude_globs) + + flags = re.IGNORECASE if case_insensitive else 0 + pattern = None + if regex and not fixed_string: + try: + pattern = re.compile(regex, flags) + except re.error as e: + raise ValueError(f"Invalid regex: {e}") + + results: list[dict] = [] + for dirpath, dirnames, filenames in os.walk(base, followlinks=follow_symlinks): + current = Path(dirpath) + if not include_hidden: + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + + for name in filenames: + if len(results) >= max_results: + return results + if not include_hidden and name.startswith("."): + continue + if includes and not _match_any(name, includes): + continue + if not _match_none(name, excludes): + continue + + file_path = current / name + if not binary: + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + except Exception: + continue + matched = False + match_lines: list[dict] = [] + if fixed_string and regex: + hay = content if not case_insensitive else content.lower() + needle = regex if not case_insensitive else regex.lower() + if needle in hay: + matched = True + for idx, line in enumerate(content.splitlines(), start=1): + if needle in (line if not case_insensitive else line.lower()): + match_lines.append({"line": idx, "text": line}) + elif pattern is not None: + for idx, line in enumerate(content.splitlines(), start=1): + if pattern.search(line): + matched = True + match_lines.append({"line": idx, "text": line}) + else: + matched = True # name matched and no content filter + + if matched: + results.append( + { + "path": str(file_path), + "match_lines": match_lines, + "match_count": len(match_lines), + } + ) + else: + # Binary search only supports name filtering (no content scan) + results.append({"path": str(file_path), "match_lines": [], "match_count": 0}) + + return results diff --git a/examples/mcp_servers/simple_server/.env.example b/examples/mcp_servers/simple_server/.env.example new file mode 100644 index 00000000..fe5a7446 --- /dev/null +++ b/examples/mcp_servers/simple_server/.env.example @@ -0,0 +1 @@ +MY_SECRET_KEY="Your tools can have secrets injected at runtime!" diff --git a/examples/mcp_servers/simple_server/pyproject.toml b/examples/mcp_servers/simple_server/pyproject.toml new file mode 100644 index 00000000..0656d926 --- /dev/null +++ b/examples/mcp_servers/simple_server/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple_server" +version = "0.1.0" +description = "MCP Server created with Arcade.dev" +requires-python = ">=3.10" +dependencies = [ + "arcade-mcp-server>=1.0.1,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "arcade-mcp[all]>=1.0.1,<2.0.0", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +# Tell Arcade.dev that this package has Arcade tools +[project.entry-points.arcade_toolkits] +toolkit_name = "simple_server" + +[tool.setuptools.packages.find] +include = ["simple_server*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +warn_unused_configs = true +disallow_untyped_defs = false diff --git a/examples/mcp_servers/simple_server/server.py b/examples/mcp_servers/simple_server/server.py new file mode 100644 index 00000000..3f9e343e --- /dev/null +++ b/examples/mcp_servers/simple_server/server.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""simple_server MCP server""" + +import sys +from typing import Annotated + +import httpx +from arcade_mcp_server import Context, MCPApp +from arcade_mcp_server.auth import Reddit + +app = MCPApp(name="simple_server", version="1.0.0", log_level="DEBUG") + + +@app.tool +def greet(name: Annotated[str, "The name of the person to greet"]) -> str: + """Greet a person by name.""" + return f"Hello, {name}!" + + +# To use this tool, you need to either set the secret in the .env file or as an environment variable +@app.tool(requires_secrets=["MY_SECRET_KEY"]) +def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]: + """Reveal the last 4 characters of a secret""" + # Secrets are injected into the context at runtime. + # LLMs and MCP clients cannot see or access your secrets + # You can define secrets in a .env file. + try: + secret = context.get_secret("MY_SECRET_KEY") + except Exception as e: + return str(e) + + return "The last 4 characters of the secret are: " + secret[-4:] + + +# To use this tool, you need to either set your ARCADE_API_KEY as an environment variable or +# use the Arcade CLI (uv pip install arcade-mcp) and run 'arcade login' to authenticate. +@app.tool(requires_auth=Reddit(scopes=["read"])) +async def get_posts_in_subreddit( + context: Context, subreddit: Annotated[str, "The name of the subreddit"] +) -> dict: + """Get posts from a specific subreddit""" + # Normalize the subreddit name + subreddit = subreddit.lower().replace("r/", "").replace(" ", "") + + # Prepare the httpx request + # OAuth token is injected into the context at runtime. + # LLMs and MCP clients cannot see or access your OAuth tokens. + oauth_token = context.get_auth_token_or_empty() + headers = { + "Authorization": f"Bearer {oauth_token}", + "User-Agent": "simple_server-mcp-server", + } + params = {"limit": 5} + url = f"https://oauth.reddit.com/r/{subreddit}/hot" + + # Make the request + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + # Return the response + return response.json() + + +# Run with specific transport +if __name__ == "__main__": + # Get transport from command line argument, default to "http" + transport = sys.argv[1] if len(sys.argv) > 1 else "http" + + # Run the server + # - "http" (default): HTTPS streaming for Cursor, VS Code, etc. + # - "stdio": Standard I/O for Claude Desktop, CLI tools, etc. + app.run(transport=transport, host="127.0.0.1", port=8000) diff --git a/examples/serving-tools/docker/Dockerfile b/examples/serving_tools/docker/Dockerfile similarity index 100% rename from examples/serving-tools/docker/Dockerfile rename to examples/serving_tools/docker/Dockerfile diff --git a/examples/serving-tools/docker/README.md b/examples/serving_tools/docker/README.md similarity index 100% rename from examples/serving-tools/docker/README.md rename to examples/serving_tools/docker/README.md diff --git a/examples/serving-tools/docker/toolkits.txt b/examples/serving_tools/docker/toolkits.txt similarity index 100% rename from examples/serving-tools/docker/toolkits.txt rename to examples/serving_tools/docker/toolkits.txt diff --git a/examples/serving-tools/modal/README.md b/examples/serving_tools/modal/README.md similarity index 100% rename from examples/serving-tools/modal/README.md rename to examples/serving_tools/modal/README.md diff --git a/examples/serving-tools/modal/run-arcade-worker.py b/examples/serving_tools/modal/run-arcade-worker.py similarity index 100% rename from examples/serving-tools/modal/run-arcade-worker.py rename to examples/serving_tools/modal/run-arcade-worker.py