Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9602cc8
test(worker/triggers): RED — missing config_schema fails declaration …
TheMostlyGreat Jun 12, 2026
dc259a6
feat(arcade-core): TriggerType declaration model (K3WQ7N GREEN)
TheMostlyGreat Jun 12, 2026
6d80f6e
test(arcade-core): RED — duplicate trigger slug within a toolkit fail…
TheMostlyGreat Jun 12, 2026
72df0d4
feat(arcade-core): validate_trigger_types rejects duplicate slugs (K3…
TheMostlyGreat Jun 12, 2026
1fa2596
test(arcade-core): RED — config_schema that is not valid JSON Schema …
TheMostlyGreat Jun 12, 2026
17f3ccb
feat(arcade-core): config_schema must be valid JSON Schema at declara…
TheMostlyGreat Jun 12, 2026
0c97294
test(arcade-serve): RED — GET /worker/triggers serves declared types …
TheMostlyGreat Jun 12, 2026
a5ee122
feat(arcade-serve): GET /worker/triggers serves declared trigger type…
TheMostlyGreat Jun 12, 2026
700f5c8
test(arcade-serve): empty trigger declarations serve {trigger_types: …
TheMostlyGreat Jun 12, 2026
fe1337f
test(arcade-serve): /worker/triggers rejects invalid worker JWT with …
TheMostlyGreat Jun 12, 2026
36188fe
test(arcade-serve): RED — Worker implementations without trigger supp…
TheMostlyGreat Jun 12, 2026
187136f
fix(arcade-serve): Worker.get_trigger_types defaults to an empty enve…
TheMostlyGreat Jun 12, 2026
f6320ab
chore: bump arcade-core 4.10.0, arcade-serve 3.4.0; raise core floor;…
TheMostlyGreat Jun 12, 2026
9ea635e
feat: discover toolkit trigger declarations
TheMostlyGreat Aug 15, 2026
5fb1ad0
feat: expose discovered toolkit triggers
TheMostlyGreat Aug 15, 2026
3bde353
test: cover integrated worker trigger catalogue
TheMostlyGreat Aug 15, 2026
38c8d09
fix(triggers): address declaration review findings
TheMostlyGreat Aug 22, 2026
935aa45
style(triggers): format declaration loader
TheMostlyGreat Aug 22, 2026
30fe91c
fix(triggers): preserve toolkit load errors
TheMostlyGreat Aug 27, 2026
900c42b
fix(catalog): suppress triggers from disabled toolkits
TheMostlyGreat Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion libs/arcade-core/arcade_core/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
ValueSchema,
)
from arcade_core.toolkit import Toolkit
from arcade_core.triggers import TriggerType, validate_trigger_types
from arcade_core.utils import (
does_function_return_value,
first_or_none,
Expand Down Expand Up @@ -159,6 +160,8 @@ class ToolCatalog(BaseModel):
_disabled_tools: set[str] = set()
_disabled_toolkits: set[str] = set()

trigger_types: list[TriggerType] = Field(default_factory=list)

def __init__(self, **data) -> None: # type: ignore[no-untyped-def]
super().__init__(**data)
self._load_disabled_tools()
Expand Down Expand Up @@ -304,10 +307,16 @@ def add_toolkit(
description: Optionally override the description of the toolkit with this parameter
"""

if str(toolkit).lower() in self._disabled_toolkits:
if toolkit.name.lower() in self._disabled_toolkits:
logger.info(f"Server '{toolkit.name!s}' is disabled and will not be cataloged.")
return

combined_trigger_types = [*self.trigger_types, *toolkit.trigger_types]
try:
validate_trigger_types(combined_trigger_types)
except ValueError as e:
raise ToolkitLoadError(str(e)).with_context(toolkit.name) from e

for module_name, tool_names in toolkit.tools.items():
for tool_name in tool_names:
try:
Expand Down Expand Up @@ -341,6 +350,8 @@ def add_toolkit(
f"Error encountered while adding tool {tool_name} from {module_name}. Reason: {e}"
).with_context(tool_name)

self.trigger_types = combined_trigger_types
Comment thread
TheMostlyGreat marked this conversation as resolved.

def __getitem__(self, name: FullyQualifiedName) -> MaterializedTool:
return self.get_tool(name)

Expand Down
28 changes: 27 additions & 1 deletion libs/arcade-core/arcade_core/toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import types
from collections import defaultdict
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any

import toml
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator

from arcade_core.errors import ToolkitLoadError
from arcade_core.parse import get_tools_from_file
from arcade_core.triggers import TriggerType, validate_trigger_types

logger = logging.getLogger(__name__)

Expand All @@ -29,6 +31,9 @@ class Toolkit(BaseModel):
tools: dict[str, list[str]] = defaultdict(list)
"""Mapping of module names to tools"""

trigger_types: list[TriggerType] = Field(default_factory=list)
"""Trigger types declared by the toolkit."""

# Other python package metadata
version: str
description: str
Expand Down Expand Up @@ -124,6 +129,7 @@ def _missing_name_error() -> ToolkitLoadError:
)

toolkit.tools = cls.tools_from_directory(package_dir, package_name)
toolkit.trigger_types = cls.trigger_types_from_directory(package_dir, package_name)

return toolkit

Expand Down Expand Up @@ -166,9 +172,29 @@ def from_package(cls, package: str) -> "Toolkit":
)

toolkit.tools = cls.tools_from_directory(package_dir, package_name)
toolkit.trigger_types = cls.trigger_types_from_directory(package_dir, package_name)

return toolkit

@staticmethod
def trigger_types_from_directory(
package_dir: Path, package_name: str | None = None
) -> list[TriggerType]:
"""Load ``<toolkit>.trigger_types.trigger_types`` when it is declared."""
if not (package_dir / "trigger_types.py").is_file():
return []

module_name = f"{package_name or package_dir.name}.trigger_types"
try:
module = importlib.import_module(module_name)
raw_declarations: Any = module.trigger_types
declarations = [TriggerType.model_validate(item) for item in raw_declarations]
validate_trigger_types(declarations)
except Exception as e:
raise ToolkitLoadError(f"Failed to load trigger types from '{module_name}': {e}") from e
else:
return declarations
Comment thread
TheMostlyGreat marked this conversation as resolved.

@classmethod
def from_entrypoint(cls, entry: importlib.metadata.EntryPoint) -> "Toolkit":
"""
Expand Down
80 changes: 80 additions & 0 deletions libs/arcade-core/arcade_core/triggers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Trigger-type declarations.

Toolkits declare trigger types as data; the worker serves them to the engine
alongside tool definitions. Validation is load-time: a broken declaration must
fail toolkit load, never reach the wire.
"""

from typing import Any, Literal

from jsonschema import exceptions as jsonschema_exceptions
from jsonschema.validators import validator_for
from pydantic import BaseModel, field_validator


class TriggerType(BaseModel):
"""A toolkit-declared trigger type."""

slug: str
"""Toolkit-namespaced identifier, e.g. "slack.message.received"."""

name: str
"""Human-readable name shown in catalogues."""

description: str
"""What the trigger watches for."""

kind: Literal["webhook", "poll"]
"""Transport kind: provider-pushed webhook or engine-driven poll."""

config_schema: dict[str, Any]
"""JSON Schema that per-instance config is validated against."""

payload_schema: dict[str, Any]
"""Schema of the normalized event delivered on fire."""

sample_payload: dict[str, Any]
"""Example payload for test-trigger UX and catalogue previews."""

version: str
"""Declaration version; rides the toolkit version axis."""

instructions: str | None = None
"""Setup guidance shown in the dashboard."""

verification: dict[str, Any] | None = None
"""Webhook-kind: named verification scheme and params executed at ingress."""

normalize_handler: str | None = None
"""Webhook-kind: tool reference invoked to normalize raw provider events."""

poll_handler: str | None = None
"""Poll-kind: tool reference invoked on the polling cadence."""

default_interval: int | None = None
"""Poll-kind: default polling interval in seconds."""

dedupe: Literal["unique", "greatest", "last"] | None = None
"""Poll-kind: watermark dedupe strategy."""

@field_validator("config_schema", "payload_schema")
@classmethod
def _schema_is_valid_json_schema(cls, value: dict[str, Any]) -> dict[str, Any]:
validator_class = validator_for(value)
try:
validator_class.check_schema(value)
except jsonschema_exceptions.SchemaError as e:
raise ValueError(f"not a valid JSON Schema: {e.message}") from e
return value


def validate_trigger_types(trigger_types: list[TriggerType]) -> None:
"""Validate a toolkit's trigger-type declarations as a collection.

Raises ValueError naming the offending slug so toolkit load fails fast.
"""
seen: set[str] = set()
for trigger_type in trigger_types:
if trigger_type.slug in seen:
raise ValueError(f"Duplicate trigger type slug: {trigger_type.slug}")
seen.add(trigger_type.slug)
4 changes: 3 additions & 1 deletion libs/arcade-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "arcade-core"
version = "4.11.0"
version = "4.11.1"
description = "Arcade Core - Core library for Arcade platform"
readme = "README.md"
license = { text = "MIT" }
Expand Down Expand Up @@ -29,6 +29,7 @@ dependencies = [
"types-pytz==2024.2.0.20241003",
"types-toml==0.10.8.20240310",
"posthog>=6.7.6,<7.0.0",
"jsonschema>=4.26.0",
]

[project.optional-dependencies]
Expand All @@ -41,6 +42,7 @@ dev = [
"types-pytz>=2024.1",
"types-python-dateutil>=2.8.2",
"types-PyYAML>=6.0.0",
"types-jsonschema>=4.26.0",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions libs/arcade-mcp-server/arcade_mcp_server/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ async def track_tasks_middleware(
otel_meter=otel_handler.get_meter(),
)
Comment thread
TheMostlyGreat marked this conversation as resolved.
worker.catalog = catalog
worker.register_trigger_types(catalog.trigger_types)
logger.info("Worker routes enabled at /worker/* (ARCADE_WORKER_SECRET is set)")

class _MCPASGIProxy:
Expand Down
6 changes: 3 additions & 3 deletions libs/arcade-mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "arcade-mcp-server"
version = "1.26.0"
version = "1.26.1"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
Expand All @@ -21,8 +21,8 @@ classifiers = [
]
requires-python = ">=3.10"
dependencies = [
"arcade-core>=4.11.0,<5.0.0",
"arcade-serve>=3.4.0,<4.0.0",
"arcade-core>=4.11.1,<5.0.0",
"arcade-serve>=3.4.3,<4.0.0",
"arcade-tdk>=3.10.1,<4.0.0",
"arcadepy>=1.5.0",
"pydantic>=2.0.0",
Expand Down
26 changes: 23 additions & 3 deletions libs/arcade-serve/arcade_serve/core/base.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
import os
import time
from collections.abc import Callable
from datetime import datetime
from typing import Any, Callable, ClassVar
from typing import Any, ClassVar

from arcade_core.catalog import ToolCatalog, Toolkit
from arcade_core.executor import ToolExecutor
Expand All @@ -12,14 +13,16 @@
ToolCallResponse,
ToolDefinition,
)
from arcade_core.triggers import TriggerType, validate_trigger_types
from opentelemetry import trace
from opentelemetry.metrics import Meter

from arcade_serve.core.common import Router, Worker
from arcade_serve.core.common import Router, TriggerTypesResponse, Worker
from arcade_serve.core.components import (
CallToolComponent,
CatalogComponent,
HealthCheckComponent,
TriggerTypesComponent,
WorkerComponent,
)

Expand All @@ -38,6 +41,7 @@ class BaseWorker(Worker):
CatalogComponent,
CallToolComponent,
HealthCheckComponent,
TriggerTypesComponent,
)

def __init__(
Expand All @@ -51,6 +55,7 @@ def __init__(
If no secret is provided, the worker will use the ARCADE_WORKER_SECRET environment variable.
"""
self.catalog = ToolCatalog()
self.trigger_types: list[TriggerType] = []
self.disable_auth = disable_auth
if disable_auth:
logger.warning(
Expand Down Expand Up @@ -97,9 +102,24 @@ def register_tool(self, tool: Callable, toolkit_name: str) -> None:

def register_toolkit(self, toolkit: Toolkit) -> None:
"""
Register a toolkit to the catalog.
Register a toolkit's tools and trigger types.
"""
self.catalog.add_toolkit(toolkit)
self.trigger_types = list(self.catalog.trigger_types)

def register_trigger_types(self, trigger_types: list[TriggerType]) -> None:
"""
Register toolkit-declared trigger types, validating the combined set.
"""
combined = [*self.trigger_types, *trigger_types]
validate_trigger_types(combined)
self.trigger_types = combined

def get_trigger_types(self) -> TriggerTypesResponse:
"""
Get the trigger types declared by the worker's toolkits.
"""
return TriggerTypesResponse(trigger_types=self.trigger_types)

async def call_tool(self, tool_request: ToolCallRequest) -> ToolCallResponse:
"""
Expand Down
23 changes: 21 additions & 2 deletions libs/arcade-serve/arcade_serve/core/common.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
from abc import ABC, abstractmethod
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from arcade_core.schema import ToolCallRequest, ToolCallResponse, ToolDefinition
from arcade_core.triggers import TriggerType
from pydantic import BaseModel

CatalogResponse = list[ToolDefinition]
HealthCheckResponse = dict[str, str]
JSONResponse = dict[str, Any]
ResponseData = CatalogResponse | ToolCallResponse | HealthCheckResponse


class TriggerTypesResponse(BaseModel):
"""Envelope for trigger-type declarations served to the engine."""

trigger_types: list[TriggerType]


ResponseData = CatalogResponse | ToolCallResponse | HealthCheckResponse | TriggerTypesResponse


class RequestData(BaseModel):
Expand Down Expand Up @@ -87,6 +97,15 @@ def health_check(self) -> HealthCheckResponse:
"""
pass

def get_trigger_types(self) -> TriggerTypesResponse:
"""
Get the trigger types declared by the worker's toolkits.

Non-abstract so existing Worker implementations keep working:
a worker that declares no trigger types serves an empty envelope.
"""
return TriggerTypesResponse(trigger_types=[])


class WorkerComponent(ABC):
def __init__(self, worker: Worker) -> None:
Expand Down
26 changes: 26 additions & 0 deletions libs/arcade-serve/arcade_serve/core/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
HealthCheckResponse,
RequestData,
Router,
TriggerTypesResponse,
WorkerComponent,
)

Expand Down Expand Up @@ -77,6 +78,31 @@ async def __call__(self, request: RequestData) -> ToolCallResponse:
return response


class TriggerTypesComponent(WorkerComponent):
def register(self, router: Router) -> None:
"""
Register the trigger-types route with the router.
"""
router.add_route(
"triggers",
self,
method="GET",
response_type=TriggerTypesResponse,
operation_id="get_trigger_types",
description="Get the trigger types declared by the worker's toolkits",
summary="Get the declared trigger types",
tags=["Arcade"],
)

async def __call__(self, request: RequestData) -> TriggerTypesResponse:
"""
Handle the request to get the declared trigger types.
"""
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("TriggerTypes"):
return self.worker.get_trigger_types()


class HealthCheckComponent(WorkerComponent):
def register(self, router: Router) -> None:
"""
Expand Down
Loading
Loading