actionlayer-mcp 0.1.0__py3-none-any.whl
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,17 @@
|
|
|
1
|
+
"""ActionLayer MCP server.
|
|
2
|
+
|
|
3
|
+
A thin Model Context Protocol shim that translates MCP tool calls into
|
|
4
|
+
ActionLayer REST API calls. Runs on the user's machine (or wherever
|
|
5
|
+
their agent host runs); reads ACTIONLAYER_API_KEY + ACTIONLAYER_API_URL
|
|
6
|
+
from the environment.
|
|
7
|
+
|
|
8
|
+
Distributed via PyPI as `actionlayer-mcp`. The console_scripts entry
|
|
9
|
+
point lets agent hosts run it as `uvx actionlayer-mcp` or
|
|
10
|
+
`pipx run actionlayer-mcp` with no clone needed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from actionlayer_mcp.client import ActionLayerClient, ApiError
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["ActionLayerClient", "ApiError", "__version__"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""HTTP client wrapping the ActionLayer REST API.
|
|
2
|
+
|
|
3
|
+
A small async wrapper kept separate from server.py so it's reusable
|
|
4
|
+
(future SDK can import it). All network IO lives here; server.py is
|
|
5
|
+
pure protocol translation.
|
|
6
|
+
|
|
7
|
+
Auth: bearer token from ACTIONLAYER_API_KEY env var. Errors are mapped
|
|
8
|
+
to ApiError with the upstream status + JSON detail string when present.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
DEFAULT_API_URL = "https://api.action-layer.dev"
|
|
20
|
+
USER_AGENT = "actionlayer-mcp/0.1.0"
|
|
21
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(RuntimeError):
|
|
25
|
+
"""Raised when the ActionLayer API returns a non-2xx response."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, detail: str):
|
|
28
|
+
super().__init__(f"ActionLayer API {status}: {detail}")
|
|
29
|
+
self.status = status
|
|
30
|
+
self.detail = detail
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ActionLayerClient:
|
|
34
|
+
"""Thin async wrapper. One instance per MCP server lifetime."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
api_key: Optional[str] = None,
|
|
40
|
+
api_url: Optional[str] = None,
|
|
41
|
+
):
|
|
42
|
+
self.api_key = api_key or os.environ.get("ACTIONLAYER_API_KEY", "")
|
|
43
|
+
self.api_url = (
|
|
44
|
+
api_url
|
|
45
|
+
or os.environ.get("ACTIONLAYER_API_URL")
|
|
46
|
+
or DEFAULT_API_URL
|
|
47
|
+
).rstrip("/")
|
|
48
|
+
if not self.api_key:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"ACTIONLAYER_API_KEY env var not set. Run "
|
|
51
|
+
"`actionlayer-mcp configure --api-key ak_...` to set it up."
|
|
52
|
+
)
|
|
53
|
+
self._client = httpx.AsyncClient(
|
|
54
|
+
base_url=self.api_url,
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
57
|
+
"User-Agent": USER_AGENT,
|
|
58
|
+
},
|
|
59
|
+
timeout=DEFAULT_TIMEOUT,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
await self._client.aclose()
|
|
64
|
+
|
|
65
|
+
# --- Tasks --------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async def start_task(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
goal: str,
|
|
71
|
+
max_budget_usd: Optional[float] = None,
|
|
72
|
+
webhook_url: Optional[str] = None,
|
|
73
|
+
idempotency_key: Optional[str] = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
# An auto-generated idempotency key gives the API a stable replay
|
|
76
|
+
# surface, but each call still gets a fresh ticket — true
|
|
77
|
+
# deduplication requires the agent to pass its own key, which is
|
|
78
|
+
# the right design (only the agent knows whether two same-arg
|
|
79
|
+
# calls are "retry" or "do it again").
|
|
80
|
+
key = idempotency_key or f"mcp-{uuid.uuid4()}"
|
|
81
|
+
body: dict[str, Any] = {"goal": goal}
|
|
82
|
+
if max_budget_usd is not None:
|
|
83
|
+
body["max_budget_usd"] = max_budget_usd
|
|
84
|
+
if webhook_url is not None:
|
|
85
|
+
body["webhook_url"] = webhook_url
|
|
86
|
+
return await self._post("/tasks", json=body, headers={"Idempotency-Key": key})
|
|
87
|
+
|
|
88
|
+
async def get_task(self, ticket_id: str) -> dict[str, Any]:
|
|
89
|
+
return await self._get(f"/tasks/{ticket_id}")
|
|
90
|
+
|
|
91
|
+
async def reply_task(
|
|
92
|
+
self,
|
|
93
|
+
ticket_id: str,
|
|
94
|
+
*,
|
|
95
|
+
message: Optional[str] = None,
|
|
96
|
+
field: Optional[str] = None,
|
|
97
|
+
value: Optional[str] = None,
|
|
98
|
+
sensitive: bool = False,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
body: dict[str, Any] = {"sensitive": sensitive}
|
|
101
|
+
if message is not None:
|
|
102
|
+
body["message"] = message
|
|
103
|
+
if field is not None:
|
|
104
|
+
body["field"] = field
|
|
105
|
+
if value is not None:
|
|
106
|
+
body["value"] = value
|
|
107
|
+
return await self._post(f"/tasks/{ticket_id}/reply", json=body)
|
|
108
|
+
|
|
109
|
+
async def cancel_task(self, ticket_id: str) -> dict[str, Any]:
|
|
110
|
+
return await self._post(f"/tasks/{ticket_id}/cancel", json=None)
|
|
111
|
+
|
|
112
|
+
# --- Skills -------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def list_skills(
|
|
115
|
+
self, *, source_format: Optional[str] = None
|
|
116
|
+
) -> list[dict[str, Any]]:
|
|
117
|
+
params: dict[str, Any] = {}
|
|
118
|
+
if source_format:
|
|
119
|
+
params["source_format"] = source_format
|
|
120
|
+
result = await self._get("/skills", params=params)
|
|
121
|
+
# /skills returns either a bare list or {"items": [...]} —
|
|
122
|
+
# accept either to stay forward-compatible.
|
|
123
|
+
if isinstance(result, dict) and "items" in result:
|
|
124
|
+
return result["items"]
|
|
125
|
+
return result # type: ignore[return-value]
|
|
126
|
+
|
|
127
|
+
# --- Actions (P10) ------------------------------------------------
|
|
128
|
+
|
|
129
|
+
async def list_actions(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
provider: Optional[str] = None,
|
|
133
|
+
category: Optional[str] = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""GET /v1/actions — returns the full catalog response shape
|
|
136
|
+
({count, actions, providers, categories}). The MCP server
|
|
137
|
+
decides what subset to surface to the agent host."""
|
|
138
|
+
params: dict[str, Any] = {}
|
|
139
|
+
if provider:
|
|
140
|
+
params["provider"] = provider
|
|
141
|
+
if category:
|
|
142
|
+
params["category"] = category
|
|
143
|
+
return await self._get("/v1/actions", params=params)
|
|
144
|
+
|
|
145
|
+
async def get_action(self, action_id: str) -> dict[str, Any]:
|
|
146
|
+
"""GET /v1/actions/{id} — full ActionDefinition with the
|
|
147
|
+
input/output schemas."""
|
|
148
|
+
return await self._get(f"/v1/actions/{action_id}")
|
|
149
|
+
|
|
150
|
+
async def invoke_action(
|
|
151
|
+
self,
|
|
152
|
+
action_id: str,
|
|
153
|
+
*,
|
|
154
|
+
inputs: dict[str, Any],
|
|
155
|
+
executor: Optional[str] = None,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""POST /v1/actions/{id} — synchronous typed dispatch.
|
|
158
|
+
|
|
159
|
+
Returns the ExecutionResult shape: action_id, executor_used,
|
|
160
|
+
outcome, output, error, blocked_reason, live_view_url,
|
|
161
|
+
cost_usd, payload. The caller (MCP server) reshapes for the
|
|
162
|
+
agent host.
|
|
163
|
+
"""
|
|
164
|
+
body: dict[str, Any] = {"inputs": inputs}
|
|
165
|
+
if executor:
|
|
166
|
+
body["executor"] = executor
|
|
167
|
+
return await self._post(f"/v1/actions/{action_id}", json=body)
|
|
168
|
+
|
|
169
|
+
# --- Jobs (P6) ----------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async def create_job(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
goal: str,
|
|
175
|
+
budget_usd: Optional[float] = None,
|
|
176
|
+
deadline: Optional[str] = None,
|
|
177
|
+
webhook_url: Optional[str] = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""POST /v1/jobs — kick off a multi-step planner-driven job.
|
|
180
|
+
|
|
181
|
+
`deadline` is ISO 8601 (e.g. '2026-05-15T19:00:00-07:00').
|
|
182
|
+
Returns the job in 'planning' state; caller polls get_job.
|
|
183
|
+
"""
|
|
184
|
+
body: dict[str, Any] = {"goal": goal}
|
|
185
|
+
if budget_usd is not None:
|
|
186
|
+
body["budget_usd"] = budget_usd
|
|
187
|
+
if deadline is not None:
|
|
188
|
+
body["deadline"] = deadline
|
|
189
|
+
if webhook_url is not None:
|
|
190
|
+
body["webhook_url"] = webhook_url
|
|
191
|
+
return await self._post("/v1/jobs", json=body)
|
|
192
|
+
|
|
193
|
+
async def get_job(self, job_id: str) -> dict[str, Any]:
|
|
194
|
+
"""GET /v1/jobs/{id} — returns the full Job + items array."""
|
|
195
|
+
return await self._get(f"/v1/jobs/{job_id}")
|
|
196
|
+
|
|
197
|
+
async def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
198
|
+
return await self._post(f"/v1/jobs/{job_id}/cancel", json=None)
|
|
199
|
+
|
|
200
|
+
async def resume_job(self, job_id: str) -> dict[str, Any]:
|
|
201
|
+
return await self._post(f"/v1/jobs/{job_id}/resume", json=None)
|
|
202
|
+
|
|
203
|
+
# --- Internals ----------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async def _get(
|
|
206
|
+
self, path: str, *, params: Optional[dict[str, Any]] = None
|
|
207
|
+
) -> Any:
|
|
208
|
+
resp = await self._client.get(path, params=params)
|
|
209
|
+
return _parse(resp)
|
|
210
|
+
|
|
211
|
+
async def _post(
|
|
212
|
+
self,
|
|
213
|
+
path: str,
|
|
214
|
+
*,
|
|
215
|
+
json: Optional[dict[str, Any]],
|
|
216
|
+
headers: Optional[dict[str, str]] = None,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
resp = await self._client.post(path, json=json, headers=headers)
|
|
219
|
+
return _parse(resp)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse(resp: httpx.Response) -> Any:
|
|
223
|
+
if resp.is_success:
|
|
224
|
+
if not resp.content:
|
|
225
|
+
return {}
|
|
226
|
+
return resp.json()
|
|
227
|
+
detail: str
|
|
228
|
+
try:
|
|
229
|
+
body = resp.json()
|
|
230
|
+
detail = body.get("detail") if isinstance(body, dict) else str(body)
|
|
231
|
+
if not isinstance(detail, str):
|
|
232
|
+
detail = str(detail)
|
|
233
|
+
except Exception:
|
|
234
|
+
detail = resp.text or resp.reason_phrase
|
|
235
|
+
raise ApiError(resp.status_code, detail)
|
|
236
|
+
|
|
237
|
+
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""ActionLayer MCP server — stdio JSON-RPC, 5 tools.
|
|
2
|
+
|
|
3
|
+
Tools (locked surface — agents will see these names):
|
|
4
|
+
|
|
5
|
+
actionlayer_start_task create a ticket
|
|
6
|
+
actionlayer_get_task read state + recent transcript
|
|
7
|
+
actionlayer_reply resume a blocked_on_user ticket
|
|
8
|
+
actionlayer_cancel cancel a non-terminal ticket
|
|
9
|
+
actionlayer_list_skills list user's skills
|
|
10
|
+
|
|
11
|
+
When a ticket is blocked_on_user, the latest event payload contains the
|
|
12
|
+
prompt the worker is waiting on. We surface that prompt in
|
|
13
|
+
actionlayer_get_task's response with `next_action` set to the prompt
|
|
14
|
+
string. Agent host then asks the human, who replies via
|
|
15
|
+
actionlayer_reply. If `sensitive=true` is on the prompt, the tool's
|
|
16
|
+
description tells the agent to ask the human directly and never echo
|
|
17
|
+
the value to other tools — the value goes straight from the user into
|
|
18
|
+
actionlayer_reply with sensitive=true.
|
|
19
|
+
|
|
20
|
+
The server is intentionally small: ~250 lines. Anything more belongs in
|
|
21
|
+
client.py or back in the API.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
from fastmcp import FastMCP
|
|
35
|
+
|
|
36
|
+
from actionlayer_mcp.client import ActionLayerClient, ApiError
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP(
|
|
39
|
+
name="actionlayer",
|
|
40
|
+
instructions=(
|
|
41
|
+
"ActionLayer is the execution layer for AI agents — one API to "
|
|
42
|
+
"call any action, hit a REST API if there is one, drive a "
|
|
43
|
+
"browser if there isn't, fall back to a human operator if the "
|
|
44
|
+
"browser gets stuck.\n\n"
|
|
45
|
+
"TWO USAGE PATTERNS:\n\n"
|
|
46
|
+
"1. TYPED ACTIONS (preferred). Call actionlayer_list_actions to "
|
|
47
|
+
"see what's available. Each action has an id like 'gmail.send_email' "
|
|
48
|
+
"with a typed input_schema. Call actionlayer_invoke_action with "
|
|
49
|
+
"the id + inputs to dispatch synchronously and get the result "
|
|
50
|
+
"inline. Examples: send an email, summarize an inbox, find free "
|
|
51
|
+
"calendar slots, book a Resy table.\n\n"
|
|
52
|
+
"2. FREE-FORM GOALS (legacy + multi-step). Call "
|
|
53
|
+
"actionlayer_start_task with a natural-language goal — the "
|
|
54
|
+
"router picks the flow and the work runs asynchronously. Poll "
|
|
55
|
+
"actionlayer_get_task. For goals that need multiple actions in "
|
|
56
|
+
"sequence (e.g. 'find a slot then email the invite'), call "
|
|
57
|
+
"actionlayer_create_job — the planner decomposes the goal and "
|
|
58
|
+
"runs the steps with output piping between them.\n\n"
|
|
59
|
+
"BLOCKED-ON-USER PATTERN. If a ticket or job goes to "
|
|
60
|
+
"'blocked_on_user', the latest event has a prompt; ask the "
|
|
61
|
+
"human, then call actionlayer_reply with the answer. If the "
|
|
62
|
+
"prompt is marked sensitive (2FA, password), DO NOT echo the "
|
|
63
|
+
"value to any other tool — pass it directly to "
|
|
64
|
+
"actionlayer_reply with sensitive=true.\n\n"
|
|
65
|
+
"BLOCKED-ON-OPERATOR PATTERN. If a browser action gets stuck "
|
|
66
|
+
"(CAPTCHA, novel UI), the ticket goes 'blocked_on_operator' and "
|
|
67
|
+
"a human teammate picks it up via the Live View URL. You don't "
|
|
68
|
+
"need to do anything — just report the status to the user."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Global client — opened on first use, closed at process exit.
|
|
74
|
+
_client: Optional[ActionLayerClient] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_client() -> ActionLayerClient:
|
|
78
|
+
global _client
|
|
79
|
+
if _client is None:
|
|
80
|
+
_client = ActionLayerClient()
|
|
81
|
+
return _client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _summarize_events(events: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
|
|
85
|
+
"""Keep responses small — agents don't need the full transcript on every poll."""
|
|
86
|
+
if not events:
|
|
87
|
+
return []
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
"type": e.get("type"),
|
|
91
|
+
"to_state": e.get("to_state"),
|
|
92
|
+
"created_at": e.get("created_at"),
|
|
93
|
+
"payload": e.get("payload"),
|
|
94
|
+
}
|
|
95
|
+
for e in events[-limit:]
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _next_action(ticket: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
100
|
+
"""If the ticket is waiting on the user, surface the prompt + field.
|
|
101
|
+
|
|
102
|
+
Returns None when the ticket is not blocked. Otherwise, returns:
|
|
103
|
+
{
|
|
104
|
+
"kind": "block_on_user",
|
|
105
|
+
"prompt": "Two-factor code from your phone?",
|
|
106
|
+
"field": "sms_code", (when present)
|
|
107
|
+
"sensitive": true|false,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Drives the agent's behavior — the description on actionlayer_reply
|
|
111
|
+
tells the agent to use field/sensitive verbatim from this object.
|
|
112
|
+
"""
|
|
113
|
+
if ticket.get("state") != "blocked_on_user":
|
|
114
|
+
return None
|
|
115
|
+
events = ticket.get("events") or []
|
|
116
|
+
block_events = [e for e in events if e.get("type") == "block_on_user"]
|
|
117
|
+
if not block_events:
|
|
118
|
+
return {"kind": "block_on_user", "prompt": "Waiting for user input.", "sensitive": False}
|
|
119
|
+
payload = block_events[-1].get("payload") or {}
|
|
120
|
+
return {
|
|
121
|
+
"kind": "block_on_user",
|
|
122
|
+
"prompt": payload.get("prompt", "Waiting for user input."),
|
|
123
|
+
"field": payload.get("field"),
|
|
124
|
+
"sensitive": bool(payload.get("sensitive", False)),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- Tools ------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
async def actionlayer_start_task(
|
|
133
|
+
goal: str,
|
|
134
|
+
max_budget_usd: Optional[float] = None,
|
|
135
|
+
webhook_url: Optional[str] = None,
|
|
136
|
+
idempotency_key: Optional[str] = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Start a new ActionLayer task.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
goal: A natural-language description of what to do. Examples:
|
|
142
|
+
"Send Alex at alex@x.com an email to set up a 30-min call"
|
|
143
|
+
"Book a table for 2 at Liholiho, Friday 7pm"
|
|
144
|
+
"Hire someone to assemble an IKEA Malm dresser this Saturday"
|
|
145
|
+
max_budget_usd: Optional spend cap. The flow refuses to commit
|
|
146
|
+
money beyond this.
|
|
147
|
+
webhook_url: Optional URL ActionLayer POSTs ticket events to.
|
|
148
|
+
idempotency_key: Optional caller-supplied retry key. If you call
|
|
149
|
+
this tool twice with the same key within 24h, you get the
|
|
150
|
+
same ticket back — safe across timeouts and retries.
|
|
151
|
+
|
|
152
|
+
Returns the ticket as {ticket_id, state, ...}. State is initially
|
|
153
|
+
"queued"; poll actionlayer_get_task to watch it progress.
|
|
154
|
+
"""
|
|
155
|
+
client = _get_client()
|
|
156
|
+
try:
|
|
157
|
+
ticket = await client.start_task(
|
|
158
|
+
goal=goal,
|
|
159
|
+
max_budget_usd=max_budget_usd,
|
|
160
|
+
webhook_url=webhook_url,
|
|
161
|
+
idempotency_key=idempotency_key,
|
|
162
|
+
)
|
|
163
|
+
except ApiError as e:
|
|
164
|
+
return {"error": str(e), "status": e.status}
|
|
165
|
+
return {
|
|
166
|
+
"ticket_id": ticket.get("id"),
|
|
167
|
+
"state": ticket.get("state"),
|
|
168
|
+
"goal": ticket.get("goal"),
|
|
169
|
+
"created_at": ticket.get("created_at"),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
async def actionlayer_get_task(ticket_id: str) -> dict[str, Any]:
|
|
175
|
+
"""Read the current state of a ticket.
|
|
176
|
+
|
|
177
|
+
Poll this until `state` is one of: completed, failed, cancelled, or
|
|
178
|
+
blocked_on_user. If blocked_on_user, the response includes a
|
|
179
|
+
`next_action` object with the prompt the human needs to answer.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
ticket_id, state, goal, flow, result (when completed),
|
|
183
|
+
error (when failed), recent_events (last 5), next_action (when
|
|
184
|
+
blocked_on_user).
|
|
185
|
+
"""
|
|
186
|
+
client = _get_client()
|
|
187
|
+
try:
|
|
188
|
+
ticket = await client.get_task(ticket_id)
|
|
189
|
+
except ApiError as e:
|
|
190
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
191
|
+
return {
|
|
192
|
+
"ticket_id": ticket.get("id"),
|
|
193
|
+
"state": ticket.get("state"),
|
|
194
|
+
"goal": ticket.get("goal"),
|
|
195
|
+
"flow": ticket.get("flow"),
|
|
196
|
+
"result": ticket.get("result"),
|
|
197
|
+
"error": ticket.get("error"),
|
|
198
|
+
"created_at": ticket.get("created_at"),
|
|
199
|
+
"completed_at": ticket.get("completed_at"),
|
|
200
|
+
"recent_events": _summarize_events(ticket.get("events") or []),
|
|
201
|
+
"next_action": _next_action(ticket),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mcp.tool()
|
|
206
|
+
async def actionlayer_reply(
|
|
207
|
+
ticket_id: str,
|
|
208
|
+
message: Optional[str] = None,
|
|
209
|
+
field: Optional[str] = None,
|
|
210
|
+
value: Optional[str] = None,
|
|
211
|
+
sensitive: bool = False,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Resume a ticket that's waiting on the user.
|
|
214
|
+
|
|
215
|
+
Use this only when actionlayer_get_task returned state
|
|
216
|
+
"blocked_on_user". The `next_action` object on that response tells
|
|
217
|
+
you the field name and whether the value is sensitive — pass them
|
|
218
|
+
through verbatim.
|
|
219
|
+
|
|
220
|
+
SENSITIVE VALUES (passwords, 2FA codes, payment info): set
|
|
221
|
+
`sensitive=true`. Pass the value the user typed directly — DO NOT
|
|
222
|
+
echo it into other tool calls, agent thoughts, or messages. The API
|
|
223
|
+
encrypts it at rest, redacts it in logs, and purges it when the
|
|
224
|
+
ticket terminates.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
ticket_id: The ticket waiting on the user.
|
|
228
|
+
message: Optional free-form note (non-secret). Lands on the
|
|
229
|
+
transcript as-is.
|
|
230
|
+
field: Machine-readable key from next_action.field.
|
|
231
|
+
value: The user's answer.
|
|
232
|
+
sensitive: True if value is a secret.
|
|
233
|
+
"""
|
|
234
|
+
client = _get_client()
|
|
235
|
+
try:
|
|
236
|
+
ticket = await client.reply_task(
|
|
237
|
+
ticket_id,
|
|
238
|
+
message=message,
|
|
239
|
+
field=field,
|
|
240
|
+
value=value,
|
|
241
|
+
sensitive=sensitive,
|
|
242
|
+
)
|
|
243
|
+
except ApiError as e:
|
|
244
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
245
|
+
return {
|
|
246
|
+
"ticket_id": ticket.get("id"),
|
|
247
|
+
"state": ticket.get("state"),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@mcp.tool()
|
|
252
|
+
async def actionlayer_cancel(ticket_id: str) -> dict[str, Any]:
|
|
253
|
+
"""Cancel a non-terminal ticket.
|
|
254
|
+
|
|
255
|
+
Idempotent on terminal states — calling cancel on a completed/
|
|
256
|
+
failed/already-cancelled ticket returns the ticket as-is.
|
|
257
|
+
"""
|
|
258
|
+
client = _get_client()
|
|
259
|
+
try:
|
|
260
|
+
ticket = await client.cancel_task(ticket_id)
|
|
261
|
+
except ApiError as e:
|
|
262
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
263
|
+
return {
|
|
264
|
+
"ticket_id": ticket.get("id"),
|
|
265
|
+
"state": ticket.get("state"),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@mcp.tool()
|
|
270
|
+
async def actionlayer_list_skills(
|
|
271
|
+
source_format: Optional[str] = None,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""List skills registered for this user.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
source_format: Optional filter — "agentskills", "hermes",
|
|
277
|
+
"openclaw", "claude-code". Omit for all.
|
|
278
|
+
|
|
279
|
+
Returns {"skills": [{id, name, description, source_format, ...}, ...]}.
|
|
280
|
+
"""
|
|
281
|
+
client = _get_client()
|
|
282
|
+
try:
|
|
283
|
+
skills = await client.list_skills(source_format=source_format)
|
|
284
|
+
except ApiError as e:
|
|
285
|
+
return {"error": str(e), "status": e.status, "skills": []}
|
|
286
|
+
return {
|
|
287
|
+
"skills": [
|
|
288
|
+
{
|
|
289
|
+
"id": s.get("id"),
|
|
290
|
+
"name": s.get("name"),
|
|
291
|
+
"description": s.get("description"),
|
|
292
|
+
"source_format": s.get("source_format"),
|
|
293
|
+
"lifecycle": s.get("lifecycle"),
|
|
294
|
+
}
|
|
295
|
+
for s in skills
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- Action catalog tools (P10) --------------------------------------
|
|
301
|
+
#
|
|
302
|
+
# These expose the typed action surface. Agents that prefer structured
|
|
303
|
+
# tool-use (vs. natural-language goals) should call these.
|
|
304
|
+
#
|
|
305
|
+
# Note: we surface the catalog as ONE generic tool
|
|
306
|
+
# (actionlayer_invoke_action) parameterized by action_id, rather than
|
|
307
|
+
# emitting 22+ separate MCP tools (gmail_send_email, gmail_summarize_inbox,
|
|
308
|
+
# ...). Build doc §16 calls this out — "one connector covers all 30+
|
|
309
|
+
# actions". The single-tool model matches Composio's surface and keeps
|
|
310
|
+
# the agent host's tool-discovery cost flat as we add actions.
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@mcp.tool()
|
|
314
|
+
async def actionlayer_list_actions(
|
|
315
|
+
provider: Optional[str] = None,
|
|
316
|
+
category: Optional[str] = None,
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
"""List available actions. Call this first to discover what's in
|
|
319
|
+
the catalog before invoking one.
|
|
320
|
+
|
|
321
|
+
Each entry has:
|
|
322
|
+
id — '<provider>.<name>', e.g. 'gmail.send_email'
|
|
323
|
+
provider — e.g. 'gmail', 'slack', 'resy'
|
|
324
|
+
category — e.g. 'communication', 'scheduling', 'dining'
|
|
325
|
+
description — what the action does
|
|
326
|
+
executors — ['api'] or ['browser']
|
|
327
|
+
auth — what the user must connect (OAuth, PAT, etc.)
|
|
328
|
+
requires_confirmation — whether the action is gated behind a
|
|
329
|
+
user-approval step (real-world bookings,
|
|
330
|
+
payments)
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
provider: Filter by provider slug ('gmail', 'slack', etc.).
|
|
334
|
+
category: Filter by category ('communication', 'scheduling',
|
|
335
|
+
etc.).
|
|
336
|
+
|
|
337
|
+
Returns {"count": int, "actions": [...], "providers": [...],
|
|
338
|
+
"categories": [...]}. The providers + categories arrays let the
|
|
339
|
+
agent build a follow-up filter without a second list call.
|
|
340
|
+
"""
|
|
341
|
+
client = _get_client()
|
|
342
|
+
try:
|
|
343
|
+
return await client.list_actions(provider=provider, category=category)
|
|
344
|
+
except ApiError as e:
|
|
345
|
+
return {"error": str(e), "status": e.status, "count": 0, "actions": []}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@mcp.tool()
|
|
349
|
+
async def actionlayer_get_action(action_id: str) -> dict[str, Any]:
|
|
350
|
+
"""Get the full definition of one action — including its
|
|
351
|
+
input_schema and output_schema (JSON Schema).
|
|
352
|
+
|
|
353
|
+
Use this to learn exactly which fields actionlayer_invoke_action
|
|
354
|
+
needs for a given action. The input_schema tells you which fields
|
|
355
|
+
are required, their types, and any constraints (enums, min/max).
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
action_id: e.g. 'gmail.send_email'.
|
|
359
|
+
|
|
360
|
+
Returns the full ActionDefinition object.
|
|
361
|
+
"""
|
|
362
|
+
client = _get_client()
|
|
363
|
+
try:
|
|
364
|
+
return await client.get_action(action_id)
|
|
365
|
+
except ApiError as e:
|
|
366
|
+
return {"error": str(e), "status": e.status, "action_id": action_id}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@mcp.tool()
|
|
370
|
+
async def actionlayer_invoke_action(
|
|
371
|
+
action_id: str,
|
|
372
|
+
inputs: dict[str, Any],
|
|
373
|
+
executor: Optional[str] = None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
"""Invoke a single action synchronously with typed inputs.
|
|
376
|
+
|
|
377
|
+
The result returns inline (no polling — unlike start_task /
|
|
378
|
+
create_job). Use this for single-step calls where you already
|
|
379
|
+
know which action to run.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
action_id: Action id from actionlayer_list_actions
|
|
383
|
+
(e.g. 'gmail.send_email').
|
|
384
|
+
inputs: Typed inputs matching the action's input_schema.
|
|
385
|
+
Required fields and types are visible via
|
|
386
|
+
actionlayer_get_action. Extra fields are rejected (422).
|
|
387
|
+
executor: Optional 'api' or 'browser'. When unset, the
|
|
388
|
+
dispatcher picks the action's preferred kind (usually
|
|
389
|
+
'api', with 'browser' as fallback for actions that have
|
|
390
|
+
both).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
outcome: 'succeeded' | 'blocked' | 'failed'
|
|
394
|
+
output: typed result matching the action's output_schema
|
|
395
|
+
error: error message if outcome != succeeded
|
|
396
|
+
blocked_reason: 'operator' | 'user' | 'auth_expired' |
|
|
397
|
+
'approval' when outcome == 'blocked'
|
|
398
|
+
live_view_url: Browserbase Live View URL when the action
|
|
399
|
+
escalated to operator (open in a browser to take over)
|
|
400
|
+
cost_usd: best-effort per-call cost
|
|
401
|
+
|
|
402
|
+
Common error responses:
|
|
403
|
+
404 — action_id not in the catalog
|
|
404
|
+
412 — auth not connected for this action's provider (the
|
|
405
|
+
human needs to OAuth-connect Google/Slack/etc., or paste
|
|
406
|
+
a GitHub PAT, before retrying)
|
|
407
|
+
422 — inputs failed input_schema validation
|
|
408
|
+
"""
|
|
409
|
+
client = _get_client()
|
|
410
|
+
try:
|
|
411
|
+
return await client.invoke_action(
|
|
412
|
+
action_id, inputs=inputs, executor=executor
|
|
413
|
+
)
|
|
414
|
+
except ApiError as e:
|
|
415
|
+
return {"error": str(e), "status": e.status, "action_id": action_id}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- Job tools (P10) -------------------------------------------------
|
|
419
|
+
#
|
|
420
|
+
# Jobs are for multi-step plans. Single actions stay on
|
|
421
|
+
# actionlayer_invoke_action (sync). Jobs are async — caller polls
|
|
422
|
+
# actionlayer_get_job until terminal.
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@mcp.tool()
|
|
426
|
+
async def actionlayer_create_job(
|
|
427
|
+
goal: str,
|
|
428
|
+
budget_usd: Optional[float] = None,
|
|
429
|
+
deadline: Optional[str] = None,
|
|
430
|
+
webhook_url: Optional[str] = None,
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
"""Create a multi-step job from a natural-language goal.
|
|
433
|
+
|
|
434
|
+
The planner (Claude Sonnet 4.6) decomposes the goal into N
|
|
435
|
+
ordered actions, possibly with output piping between them.
|
|
436
|
+
Examples:
|
|
437
|
+
"Find a 30-min slot next week, then email alex@x.com to
|
|
438
|
+
schedule it"
|
|
439
|
+
"Summarize my unread emails, then post the count to
|
|
440
|
+
Slack #general"
|
|
441
|
+
|
|
442
|
+
Returns the job in 'planning' state. Poll actionlayer_get_job
|
|
443
|
+
until state is one of: complete, partial, failed, cancelled,
|
|
444
|
+
blocked.
|
|
445
|
+
|
|
446
|
+
Use actionlayer_invoke_action for single-action goals — it's
|
|
447
|
+
sync and skips the planner round-trip.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
goal: Natural-language description of the work.
|
|
451
|
+
budget_usd: Optional total spend cap.
|
|
452
|
+
deadline: Optional ISO 8601 deadline (e.g.
|
|
453
|
+
'2026-05-15T19:00:00-07:00'). Planner picks faster
|
|
454
|
+
actions when deadlines are tight.
|
|
455
|
+
webhook_url: HTTPS URL POSTed on terminal state. Optional.
|
|
456
|
+
|
|
457
|
+
Returns the Job row: {id, goal, state, plan, budget_usd, ...}.
|
|
458
|
+
"""
|
|
459
|
+
client = _get_client()
|
|
460
|
+
body: dict[str, Any] = {"goal": goal}
|
|
461
|
+
if budget_usd is not None:
|
|
462
|
+
body["budget_usd"] = budget_usd
|
|
463
|
+
if deadline is not None:
|
|
464
|
+
body["deadline"] = deadline
|
|
465
|
+
if webhook_url is not None:
|
|
466
|
+
body["webhook_url"] = webhook_url
|
|
467
|
+
try:
|
|
468
|
+
return await client.create_job(
|
|
469
|
+
goal=goal,
|
|
470
|
+
budget_usd=budget_usd,
|
|
471
|
+
deadline=deadline,
|
|
472
|
+
webhook_url=webhook_url,
|
|
473
|
+
)
|
|
474
|
+
except ApiError as e:
|
|
475
|
+
return {"error": str(e), "status": e.status}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@mcp.tool()
|
|
479
|
+
async def actionlayer_get_job(job_id: str) -> dict[str, Any]:
|
|
480
|
+
"""Read a job's current state + per-step items.
|
|
481
|
+
|
|
482
|
+
Terminal states: complete, partial, failed, cancelled. Non-terminal:
|
|
483
|
+
planning, executing, blocked.
|
|
484
|
+
|
|
485
|
+
`items` is the list of plan steps; each has its own state,
|
|
486
|
+
inputs, result, and (if blocked) the reason.
|
|
487
|
+
|
|
488
|
+
Returns the Job row with `items` populated.
|
|
489
|
+
"""
|
|
490
|
+
client = _get_client()
|
|
491
|
+
try:
|
|
492
|
+
return await client.get_job(job_id)
|
|
493
|
+
except ApiError as e:
|
|
494
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@mcp.tool()
|
|
498
|
+
async def actionlayer_cancel_job(job_id: str) -> dict[str, Any]:
|
|
499
|
+
"""Cancel an in-flight job. Idempotent on terminal states.
|
|
500
|
+
|
|
501
|
+
Note: cancellation is cooperative-on-fetch — if a job is
|
|
502
|
+
mid-step when this is called, the current step finishes before
|
|
503
|
+
the job transitions to cancelled.
|
|
504
|
+
"""
|
|
505
|
+
client = _get_client()
|
|
506
|
+
try:
|
|
507
|
+
return await client.cancel_job(job_id)
|
|
508
|
+
except ApiError as e:
|
|
509
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@mcp.tool()
|
|
513
|
+
async def actionlayer_resume_job(job_id: str) -> dict[str, Any]:
|
|
514
|
+
"""Resume a job that's in 'blocked' state.
|
|
515
|
+
|
|
516
|
+
Use after the operator or user has resolved the blocker
|
|
517
|
+
(e.g., the operator finished the CAPTCHA on the Live View URL,
|
|
518
|
+
or the user replied with a 2FA code via actionlayer_reply).
|
|
519
|
+
The worker re-evaluates the plan and continues from where it
|
|
520
|
+
paused.
|
|
521
|
+
|
|
522
|
+
Returns 409 if the job isn't in 'blocked' state.
|
|
523
|
+
"""
|
|
524
|
+
client = _get_client()
|
|
525
|
+
try:
|
|
526
|
+
return await client.resume_job(job_id)
|
|
527
|
+
except ApiError as e:
|
|
528
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --- Configure subcommand --------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _configure(api_key: str, api_url: Optional[str]) -> None:
|
|
535
|
+
"""Write a basic Claude Desktop / Claude Code MCP config snippet to stdout.
|
|
536
|
+
|
|
537
|
+
Doesn't auto-edit the user's config file — printing keeps the action
|
|
538
|
+
visible and lets the user paste into whichever host they use.
|
|
539
|
+
"""
|
|
540
|
+
snippet = {
|
|
541
|
+
"mcpServers": {
|
|
542
|
+
"actionlayer": {
|
|
543
|
+
"command": "uvx",
|
|
544
|
+
"args": ["actionlayer-mcp"],
|
|
545
|
+
"env": {
|
|
546
|
+
"ACTIONLAYER_API_KEY": api_key,
|
|
547
|
+
**({"ACTIONLAYER_API_URL": api_url} if api_url else {}),
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
print("Add this to your MCP host config (Claude Desktop, Cursor, etc.):\n")
|
|
553
|
+
print(json.dumps(snippet, indent=2))
|
|
554
|
+
print()
|
|
555
|
+
home_hint = Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
|
|
556
|
+
if sys.platform == "darwin":
|
|
557
|
+
print(f"On macOS, Claude Desktop config lives at:\n {home_hint}")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# --- Entry point -----------------------------------------------------
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def main() -> None:
|
|
564
|
+
parser = argparse.ArgumentParser(
|
|
565
|
+
prog="actionlayer-mcp",
|
|
566
|
+
description="ActionLayer MCP server (Model Context Protocol).",
|
|
567
|
+
)
|
|
568
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
569
|
+
|
|
570
|
+
cfg = sub.add_parser(
|
|
571
|
+
"configure",
|
|
572
|
+
help="Print MCP host config snippet for a given API key.",
|
|
573
|
+
)
|
|
574
|
+
cfg.add_argument("--api-key", required=True)
|
|
575
|
+
cfg.add_argument("--api-url", default=None)
|
|
576
|
+
|
|
577
|
+
args = parser.parse_args()
|
|
578
|
+
|
|
579
|
+
if args.cmd == "configure":
|
|
580
|
+
_configure(args.api_key, args.api_url)
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
# Default: run the MCP server over stdio. The host (Claude Code,
|
|
584
|
+
# Cursor, etc.) launches us with stdin/stdout connected and speaks
|
|
585
|
+
# MCP JSON-RPC. fastmcp.run() blocks until the host disconnects.
|
|
586
|
+
if not os.environ.get("ACTIONLAYER_API_KEY"):
|
|
587
|
+
print(
|
|
588
|
+
"ACTIONLAYER_API_KEY env var is not set. Either set it in your "
|
|
589
|
+
"MCP host config (recommended) or run "
|
|
590
|
+
"`actionlayer-mcp configure --api-key ak_...` for setup help.",
|
|
591
|
+
file=sys.stderr,
|
|
592
|
+
)
|
|
593
|
+
sys.exit(2)
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
asyncio.run(mcp.run_async())
|
|
597
|
+
except KeyboardInterrupt:
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if __name__ == "__main__":
|
|
602
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: actionlayer-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for ActionLayer — give Claude Code and other MCP hosts access to ActionLayer's typed action catalog.
|
|
5
|
+
Project-URL: Homepage, https://action-layer.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/grimjjow/actionlayer
|
|
7
|
+
Author: ActionLayer
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: actionlayer,agents,claude,claude-code,mcp,tools
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: fastmcp>=0.4.0
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# actionlayer-mcp
|
|
24
|
+
|
|
25
|
+
MCP server for [ActionLayer](https://action-layer.dev) — give Claude Code (and any MCP host) access to ActionLayer's typed action catalog: 30+ actions across Gmail, Calendar, Drive, Slack, Notion, GitHub, Stripe, Linear, Resy, Partiful, USPS, and more.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Get an API key at https://action-layer.dev/join, then:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude mcp add --scope user actionlayer "uvx" "actionlayer-mcp" \
|
|
33
|
+
--env ACTIONLAYER_API_KEY="ak_…" \
|
|
34
|
+
--env ACTIONLAYER_API_URL="https://api.action-layer.dev"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Restart Claude Code and try:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Summarize my 3 most recent unread emails.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
This package is a thin shim. The actual execution lives on ActionLayer's
|
|
46
|
+
service. The MCP server here exposes one tool — `actionlayer_invoke_action` —
|
|
47
|
+
plus helpers to list the catalog, create jobs, and manage the cyborg loop.
|
|
48
|
+
Auth is via your bearer API key; the server proxies calls and surfaces
|
|
49
|
+
the typed response back to your agent host.
|
|
50
|
+
|
|
51
|
+
## Usage from other MCP hosts
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# stdio transport (default)
|
|
55
|
+
actionlayer-mcp
|
|
56
|
+
|
|
57
|
+
# also works with Cursor, Cody, Codex, etc. — any MCP host
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Env vars:
|
|
61
|
+
- `ACTIONLAYER_API_KEY` — required, your bearer key
|
|
62
|
+
- `ACTIONLAYER_API_URL` — defaults to `https://api.action-layer.dev`
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
actionlayer_mcp/__init__.py,sha256=PzEAqCZn4vaRnUcZBPoiYhubKadMP8RYYjsy6do3qkI,589
|
|
2
|
+
actionlayer_mcp/client.py,sha256=mpqASnsYyvlEQuHrPVsCH7_1caqn9GRKEz2vwYyslJo,8221
|
|
3
|
+
actionlayer_mcp/server.py,sha256=Jy4wcqnfCKC0Zv6BxbN4CjZ-Nz0jd2en02u7ta6wWeE,21516
|
|
4
|
+
actionlayer_mcp-0.1.0.dist-info/METADATA,sha256=25TgaDFc10BDr9FVeHu10NE94iFxKvsgJmWO0rSXxtY,2170
|
|
5
|
+
actionlayer_mcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
actionlayer_mcp-0.1.0.dist-info/entry_points.txt,sha256=l44oeiUnLkXEYGOVroTSw89qr72IE_asQ1jOBzlrLOQ,64
|
|
7
|
+
actionlayer_mcp-0.1.0.dist-info/RECORD,,
|