arcade-serve 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ .DS_Store
2
+ credentials.yaml
3
+ docker/credentials.yaml
4
+
5
+ *.lock
6
+
7
+ # example data
8
+ examples/data
9
+ scratch
10
+
11
+
12
+ docs/source
13
+
14
+ # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
15
+
16
+ # Byte-compiled / optimized / DLL files
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+
21
+ # C extensions
22
+ *.so
23
+
24
+ # Distribution / packaging
25
+ .Python
26
+ build/
27
+ develop-eggs/
28
+ dist/
29
+ downloads/
30
+ eggs/
31
+ .eggs/
32
+ lib/
33
+ lib64/
34
+ parts/
35
+ sdist/
36
+ var/
37
+ wheels/
38
+ share/python-wheels/
39
+ *.egg-info/
40
+ .installed.cfg
41
+ *.egg
42
+ MANIFEST
43
+
44
+ # PyInstaller
45
+ # Usually these files are written by a python script from a template
46
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
47
+ *.manifest
48
+ *.spec
49
+
50
+ # Installer logs
51
+ pip-log.txt
52
+ pip-delete-this-directory.txt
53
+
54
+ # Unit test / coverage reports
55
+ htmlcov/
56
+ .tox/
57
+ .nox/
58
+ .coverage
59
+ .coverage.*
60
+ .cache
61
+ nosetests.xml
62
+ coverage.xml
63
+ *.cover
64
+ *.py,cover
65
+ .hypothesis/
66
+ .pytest_cache/
67
+ cover/
68
+
69
+ # Translations
70
+ *.mo
71
+ *.pot
72
+
73
+ # Django stuff:
74
+ *.log
75
+ local_settings.py
76
+ db.sqlite3
77
+ db.sqlite3-journal
78
+
79
+ # Flask stuff:
80
+ instance/
81
+ .webassets-cache
82
+
83
+ # Scrapy stuff:
84
+ .scrapy
85
+
86
+ # Sphinx documentation
87
+ docs/_build/
88
+
89
+ # PyBuilder
90
+ .pybuilder/
91
+ target/
92
+
93
+ # Jupyter Notebook
94
+ .ipynb_checkpoints
95
+
96
+ # IPython
97
+ profile_default/
98
+ ipython_config.py
99
+
100
+ # pyenv
101
+ # For a library or package, you might want to ignore these files since the code is
102
+ # intended to run in multiple environments; otherwise, check them in:
103
+ # .python-version
104
+
105
+ # pipenv
106
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
107
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
108
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
109
+ # install all needed dependencies.
110
+ #Pipfile.lock
111
+
112
+ # poetry
113
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
114
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
115
+ # commonly ignored for libraries.
116
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
117
+ poetry.lock
118
+
119
+ # pdm
120
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
121
+ #pdm.lock
122
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
123
+ # in version control.
124
+ # https://pdm.fming.dev/#use-with-ide
125
+ .pdm.toml
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .venv
140
+ env/
141
+ venv/
142
+ ENV/
143
+ env.bak/
144
+ venv.bak/
145
+
146
+ # Spyder project settings
147
+ .spyderproject
148
+ .spyproject
149
+
150
+ # Rope project settings
151
+ .ropeproject
152
+
153
+ # mkdocs documentation
154
+ /site
155
+
156
+ # mypy
157
+ .mypy_cache/
158
+ .dmypy.json
159
+ dmypy.json
160
+
161
+ # Pyre type checker
162
+ .pyre/
163
+
164
+ # pytype static type analyzer
165
+ .pytype/
166
+
167
+ # Cython debug symbols
168
+ cython_debug/
169
+
170
+ # PyCharm
171
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
172
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
173
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
174
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
175
+ #.idea/
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: arcade-serve
3
+ Version: 2.0.0
4
+ Summary: Arcade Serve - Serving infrastructure for Arcade tools and workers
5
+ Author-email: Arcade <dev@arcade.dev>
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: arcade-core<3.0.0,>=2.0.0
17
+ Requires-Dist: fastapi>=0.115.3
18
+ Requires-Dist: uvicorn>=0.30.0
19
+ Requires-Dist: watchfiles>=1.0.5
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.5.1; extra == 'dev'
22
+ Requires-Dist: pre-commit>=3.4.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23.7; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.1.2; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Arcade Serve
29
+
30
+ Serving infrastructure for Arcade tools and workers.
31
+
32
+ ## Overview
33
+
34
+ Arcade Serve provides the infrastructure for serving Arcade tools:
35
+
36
+ - **FastAPI Worker**: High-performance FastAPI-based worker implementation
37
+ - **MCP Server**: Model Context Protocol server for tool integration
38
+ - **Core Abstractions**: Base worker classes and components
39
+ - **Authentication**: Auth utilities and routing
40
+ - **Runtime Management**: Tool execution and lifecycle management
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install arcade-serve
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ To add a toolkit to a hosted worker such as FastAPI, you can register them in the worker itself.
51
+ This allows you to explicitly define which tools should be included on a particular worker.
52
+
53
+
54
+ Here is an example of adding the math toolkit (pip install arcade-math) to a FastAPI Worker:
55
+ ```python
56
+ import arcade_math
57
+ from fastapi import FastAPI
58
+ from arcade_tdk import Toolkit
59
+ from arcade_serve.fastapi import FastAPIWorker
60
+
61
+ app = FastAPI()
62
+
63
+ worker_secret = os.environ.get("ARCADE_WORKER_SECRET")
64
+ worker = FastAPIWorker(app, secret=worker_secret)
65
+
66
+ worker.register_toolkit(Toolkit.from_module(arcade_math))
67
+ ```
68
+
69
+ Here is an example of adding the math toolkit (pip install arcade-math) to a MCP Worker
70
+ ```python
71
+ import arcade_math
72
+ from arcade_core.catalog import ToolCatalog
73
+ from arcade_serve.mcp.stdio import StdioServer
74
+
75
+ # 1. Create and populate the tool catalog
76
+ catalog = ToolCatalog()
77
+ catalog.add_module(arcade_math)
78
+
79
+
80
+ # 2. Main entrypoint
81
+ async def main():
82
+ # Create the worker with the tool catalog
83
+ worker = StdioServer(catalog)
84
+
85
+ # Run the worker
86
+ await worker.run()
87
+
88
+
89
+ if __name__ == "__main__":
90
+ import asyncio
91
+
92
+ asyncio.run(main())
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT License - see LICENSE file for details.
@@ -0,0 +1,70 @@
1
+ # Arcade Serve
2
+
3
+ Serving infrastructure for Arcade tools and workers.
4
+
5
+ ## Overview
6
+
7
+ Arcade Serve provides the infrastructure for serving Arcade tools:
8
+
9
+ - **FastAPI Worker**: High-performance FastAPI-based worker implementation
10
+ - **MCP Server**: Model Context Protocol server for tool integration
11
+ - **Core Abstractions**: Base worker classes and components
12
+ - **Authentication**: Auth utilities and routing
13
+ - **Runtime Management**: Tool execution and lifecycle management
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install arcade-serve
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ To add a toolkit to a hosted worker such as FastAPI, you can register them in the worker itself.
24
+ This allows you to explicitly define which tools should be included on a particular worker.
25
+
26
+
27
+ Here is an example of adding the math toolkit (pip install arcade-math) to a FastAPI Worker:
28
+ ```python
29
+ import arcade_math
30
+ from fastapi import FastAPI
31
+ from arcade_tdk import Toolkit
32
+ from arcade_serve.fastapi import FastAPIWorker
33
+
34
+ app = FastAPI()
35
+
36
+ worker_secret = os.environ.get("ARCADE_WORKER_SECRET")
37
+ worker = FastAPIWorker(app, secret=worker_secret)
38
+
39
+ worker.register_toolkit(Toolkit.from_module(arcade_math))
40
+ ```
41
+
42
+ Here is an example of adding the math toolkit (pip install arcade-math) to a MCP Worker
43
+ ```python
44
+ import arcade_math
45
+ from arcade_core.catalog import ToolCatalog
46
+ from arcade_serve.mcp.stdio import StdioServer
47
+
48
+ # 1. Create and populate the tool catalog
49
+ catalog = ToolCatalog()
50
+ catalog.add_module(arcade_math)
51
+
52
+
53
+ # 2. Main entrypoint
54
+ async def main():
55
+ # Create the worker with the tool catalog
56
+ worker = StdioServer(catalog)
57
+
58
+ # Run the worker
59
+ await worker.run()
60
+
61
+
62
+ if __name__ == "__main__":
63
+ import asyncio
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT License - see LICENSE file for details.
File without changes
File without changes
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+ import jwt
6
+
7
+ SUPPORTED_TOKEN_VER = "1" # noqa: S105 Possible hardcoded password assigned (false positive)
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class TokenValidationResult:
14
+ valid: bool
15
+ error: str | None = None
16
+
17
+
18
+ class SigningAlgorithm(str, Enum):
19
+ HS256 = "HS256"
20
+
21
+
22
+ def validate_engine_token(worker_secret: str, token: str) -> TokenValidationResult:
23
+ try:
24
+ payload = jwt.decode(
25
+ token,
26
+ worker_secret,
27
+ algorithms=[SigningAlgorithm.HS256],
28
+ verify=True,
29
+ audience="worker",
30
+ )
31
+ except jwt.InvalidSignatureError as e:
32
+ logger.warning(
33
+ "Invalid signature. Is the Arcade Engine configured with the Worker secret '%s'?",
34
+ worker_secret,
35
+ )
36
+ return TokenValidationResult(valid=False, error=str(e))
37
+
38
+ except jwt.InvalidTokenError as e:
39
+ return TokenValidationResult(valid=False, error=str(e))
40
+
41
+ token_ver = payload.get("ver")
42
+ if token_ver != SUPPORTED_TOKEN_VER:
43
+ return TokenValidationResult(valid=False, error=f"Unsupported token version: {token_ver}")
44
+
45
+ return TokenValidationResult(valid=True)
@@ -0,0 +1,185 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from datetime import datetime
5
+ from typing import Any, Callable, ClassVar
6
+
7
+ from arcade_core.catalog import ToolCatalog, Toolkit
8
+ from arcade_core.executor import ToolExecutor
9
+ from arcade_core.schema import (
10
+ ToolCallRequest,
11
+ ToolCallResponse,
12
+ ToolDefinition,
13
+ )
14
+ from opentelemetry import trace
15
+ from opentelemetry.metrics import Meter
16
+
17
+ from arcade_serve.core.common import Router, Worker
18
+ from arcade_serve.core.components import (
19
+ CallToolComponent,
20
+ CatalogComponent,
21
+ HealthCheckComponent,
22
+ WorkerComponent,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class BaseWorker(Worker):
29
+ """
30
+ A base worker class that provides a default implementation for registering tools and invoking them.
31
+ Worker implementations for specific web frameworks will inherit from this class.
32
+ """
33
+
34
+ base_path = "/worker" # By default, prefix all our routes with /worker
35
+
36
+ default_components: ClassVar[tuple[type[WorkerComponent], ...]] = (
37
+ CatalogComponent,
38
+ CallToolComponent,
39
+ HealthCheckComponent,
40
+ )
41
+
42
+ def __init__(
43
+ self, secret: str | None = None, disable_auth: bool = False, otel_meter: Meter | None = None
44
+ ) -> None:
45
+ """
46
+ Initialize the BaseWorker with an empty ToolCatalog.
47
+ If no secret is provided, the worker will use the ARCADE_WORKER_SECRET environment variable.
48
+ """
49
+ self.catalog = ToolCatalog()
50
+ self.disable_auth = disable_auth
51
+ if disable_auth:
52
+ logger.warning(
53
+ "Warning: Worker is running without authentication. Not recommended for production."
54
+ )
55
+
56
+ self.secret = self._set_secret(secret, disable_auth)
57
+ self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
58
+
59
+ self.tool_counter = None
60
+ if otel_meter:
61
+ self.tool_counter = otel_meter.create_counter(
62
+ "tool_call", "requests", "Total number of tools called"
63
+ )
64
+
65
+ def _set_secret(self, secret: str | None, disable_auth: bool) -> str:
66
+ if disable_auth:
67
+ return ""
68
+
69
+ # If secret is provided, use it
70
+ if secret:
71
+ return secret
72
+
73
+ # If secret is not provided, try to get it from environment variables
74
+ env_secret = os.environ.get("ARCADE_WORKER_SECRET")
75
+ if env_secret:
76
+ return env_secret
77
+
78
+ raise ValueError(
79
+ "No secret provided for worker. Set the ARCADE_WORKER_SECRET environment variable."
80
+ )
81
+
82
+ def get_catalog(self) -> list[ToolDefinition]:
83
+ """
84
+ Get the catalog as a list of ToolDefinitions.
85
+ """
86
+ return [tool.definition for tool in self.catalog]
87
+
88
+ def register_tool(self, tool: Callable, toolkit_name: str) -> None:
89
+ """
90
+ Register a tool to the catalog.
91
+ """
92
+ self.catalog.add_tool(tool, toolkit_name)
93
+
94
+ def register_toolkit(self, toolkit: Toolkit) -> None:
95
+ """
96
+ Register a toolkit to the catalog.
97
+ """
98
+ self.catalog.add_toolkit(toolkit)
99
+
100
+ async def call_tool(self, tool_request: ToolCallRequest) -> ToolCallResponse:
101
+ """
102
+ Call (invoke) a tool using the ToolExecutor.
103
+ """
104
+ tool_fqname = tool_request.tool.get_fully_qualified_name()
105
+
106
+ try:
107
+ materialized_tool = self.catalog.get_tool(tool_fqname)
108
+ except KeyError:
109
+ raise ValueError(
110
+ f"Tool {tool_fqname} not found in catalog with toolkit version {tool_request.tool.version}."
111
+ )
112
+
113
+ start_time = time.time()
114
+
115
+ if self.tool_counter:
116
+ self.tool_counter.add(
117
+ 1,
118
+ {
119
+ "tool_name": tool_fqname.name,
120
+ "toolkit_version": str(tool_fqname.toolkit_version),
121
+ "toolkit_name": tool_fqname.toolkit_name,
122
+ "environment": self.environment,
123
+ },
124
+ )
125
+ execution_id = tool_request.execution_id or ""
126
+ logger.info(
127
+ f"{execution_id} | Calling tool: {tool_fqname} version: {tool_request.tool.version}"
128
+ )
129
+ logger.debug(f"{execution_id} | Tool inputs: {tool_request.inputs}")
130
+
131
+ tracer = trace.get_tracer(__name__)
132
+ with tracer.start_as_current_span("RunTool"):
133
+ output = await ToolExecutor.run(
134
+ func=materialized_tool.tool,
135
+ definition=materialized_tool.definition,
136
+ input_model=materialized_tool.input_model,
137
+ output_model=materialized_tool.output_model,
138
+ context=tool_request.context,
139
+ **tool_request.inputs or {},
140
+ )
141
+
142
+ end_time = time.time() # End time in seconds
143
+ duration_ms = (end_time - start_time) * 1000 # Convert to milliseconds
144
+
145
+ if output.error:
146
+ logger.warning(
147
+ f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} failed"
148
+ )
149
+ logger.warning(f"{execution_id} | Tool error: {output.error.message}")
150
+ logger.warning(
151
+ f"{execution_id} | Tool developer message: {output.error.developer_message}"
152
+ )
153
+ logger.debug(
154
+ f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
155
+ )
156
+ if output.error.traceback_info:
157
+ logger.debug(f"{execution_id} | Tool traceback: {output.error.traceback_info}")
158
+ else:
159
+ logger.info(
160
+ f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} success"
161
+ )
162
+ logger.debug(
163
+ f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
164
+ )
165
+
166
+ return ToolCallResponse(
167
+ execution_id=execution_id,
168
+ duration=duration_ms,
169
+ finished_at=datetime.now().isoformat(),
170
+ success=not output.error,
171
+ output=output,
172
+ )
173
+
174
+ def health_check(self) -> dict[str, Any]:
175
+ """
176
+ Provide a health check that serves as a heartbeat of worker health.
177
+ """
178
+ return {"status": "ok", "tool_count": str(len(self.catalog))}
179
+
180
+ def register_routes(self, router: Router) -> None:
181
+ """
182
+ Register the necessary routes to the application.
183
+ """
184
+ for component_cls in self.default_components:
185
+ component_cls(self).register(router)
@@ -0,0 +1,93 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable
3
+
4
+ from arcade_core.schema import ToolCallRequest, ToolCallResponse, ToolDefinition
5
+ from pydantic import BaseModel
6
+
7
+ CatalogResponse = list[ToolDefinition]
8
+ HealthCheckResponse = dict[str, str]
9
+ JSONResponse = dict[str, Any]
10
+ ResponseData = CatalogResponse | ToolCallResponse | HealthCheckResponse
11
+
12
+
13
+ class RequestData(BaseModel):
14
+ """
15
+ The raw data for a request to a worker.
16
+ This is not intended to represent everything about an HTTP request,
17
+ but just the essential info a framework integration will need to extract from the request.
18
+ """
19
+
20
+ path: str
21
+ """The path of the request."""
22
+ method: str
23
+ """The method of the request."""
24
+ body_json: JSONResponse | None = None
25
+ """The deserialized body of the request (e.g. JSON)"""
26
+
27
+
28
+ class Router(ABC):
29
+ """
30
+ A router is responsible for adding routes to the underlying framework hosting the worker.
31
+ """
32
+
33
+ @abstractmethod
34
+ def add_route(
35
+ self,
36
+ endpoint_path: str,
37
+ handler: Callable,
38
+ method: str,
39
+ require_auth: bool = True,
40
+ response_type: type[ResponseData] | None = None,
41
+ **kwargs: Any,
42
+ ) -> None:
43
+ """
44
+ Add a route to the router.
45
+ """
46
+ pass
47
+
48
+
49
+ class Worker(ABC):
50
+ """
51
+ A Worker represents a collection of tools that is hosted inside a web framework
52
+ and can be called by an Engine.
53
+ """
54
+
55
+ @abstractmethod
56
+ def get_catalog(self) -> CatalogResponse:
57
+ """
58
+ Get the catalog of tools available in the worker.
59
+ """
60
+ pass
61
+
62
+ @abstractmethod
63
+ async def call_tool(self, request: ToolCallRequest) -> ToolCallResponse:
64
+ """
65
+ Send a request to call a tool to the Worker
66
+ """
67
+ pass
68
+
69
+ @abstractmethod
70
+ def health_check(self) -> HealthCheckResponse:
71
+ """
72
+ Perform a health check of the worker
73
+ """
74
+ pass
75
+
76
+
77
+ class WorkerComponent(ABC):
78
+ def __init__(self, worker: Worker) -> None:
79
+ self.worker = worker
80
+
81
+ @abstractmethod
82
+ def register(self, router: Router) -> None:
83
+ """
84
+ Register the component with the given router.
85
+ """
86
+ pass
87
+
88
+ @abstractmethod
89
+ async def __call__(self, request: RequestData) -> ResponseData:
90
+ """
91
+ Handle the request.
92
+ """
93
+ pass