actionlayer-mcp 0.1.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,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .Python
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .venv/
10
+ venv/
11
+ env/
12
+ *.egg
13
+
14
+ # Env / secrets
15
+ .env
16
+ .env.local
17
+ .env.*.local
18
+
19
+ # Testing
20
+ .pytest_cache/
21
+ .coverage
22
+ .coverage.*
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # Cloudflare Pages / Vercel (legacy)
28
+ .vercel/
29
+
30
+ # Fly
31
+ .fly/
32
+
33
+ # Bun + Node (for site/)
34
+ node_modules/
35
+ .next/
36
+ .vite/
37
+ .cache/
38
+ *.tsbuildinfo
39
+
40
+ # IDE
41
+ .idea/
42
+ .vscode/
43
+ *.swp
44
+ *.swo
45
+
46
+ # OS
47
+ .DS_Store
48
+ Thumbs.db
@@ -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,44 @@
1
+ # actionlayer-mcp
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ Get an API key at https://action-layer.dev/join, then:
8
+
9
+ ```bash
10
+ claude mcp add --scope user actionlayer "uvx" "actionlayer-mcp" \
11
+ --env ACTIONLAYER_API_KEY="ak_…" \
12
+ --env ACTIONLAYER_API_URL="https://api.action-layer.dev"
13
+ ```
14
+
15
+ Restart Claude Code and try:
16
+
17
+ ```
18
+ Summarize my 3 most recent unread emails.
19
+ ```
20
+
21
+ ## How it works
22
+
23
+ This package is a thin shim. The actual execution lives on ActionLayer's
24
+ service. The MCP server here exposes one tool — `actionlayer_invoke_action` —
25
+ plus helpers to list the catalog, create jobs, and manage the cyborg loop.
26
+ Auth is via your bearer API key; the server proxies calls and surfaces
27
+ the typed response back to your agent host.
28
+
29
+ ## Usage from other MCP hosts
30
+
31
+ ```bash
32
+ # stdio transport (default)
33
+ actionlayer-mcp
34
+
35
+ # also works with Cursor, Cody, Codex, etc. — any MCP host
36
+ ```
37
+
38
+ Env vars:
39
+ - `ACTIONLAYER_API_KEY` — required, your bearer key
40
+ - `ACTIONLAYER_API_URL` — defaults to `https://api.action-layer.dev`
41
+
42
+ ## License
43
+
44
+ MIT
@@ -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,54 @@
1
+ # Standalone PyPI package for the ActionLayer MCP server.
2
+ #
3
+ # This pyproject lives SEPARATE from the main repo's pyproject.toml
4
+ # because the main one ships the whole server (SQLAlchemy, Alembic,
5
+ # browser-use, anthropic, ...) which would be ~600MB of deps for
6
+ # teammates who just want to wire ActionLayer into Claude Code.
7
+ #
8
+ # This package contains only the MCP shim (fastmcp + httpx). Source is
9
+ # kept in sync via the publish_mcp.sh script in this directory.
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [project]
16
+ name = "actionlayer-mcp"
17
+ version = "0.1.0"
18
+ description = "MCP server for ActionLayer — give Claude Code and other MCP hosts access to ActionLayer's typed action catalog."
19
+ readme = "README.md"
20
+ requires-python = ">=3.10"
21
+ license = {text = "MIT"}
22
+ authors = [{name = "ActionLayer"}]
23
+ keywords = ["mcp", "claude", "claude-code", "actionlayer", "agents", "tools"]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Software Development :: Libraries",
33
+ ]
34
+
35
+ dependencies = [
36
+ # MCP server framework. Pin minimum to a version with stdio + sse
37
+ # transports we use.
38
+ "fastmcp>=0.4.0",
39
+ # HTTP client used to talk to the ActionLayer REST API. Async
40
+ # mode in the server, sync mode in scripts.
41
+ "httpx>=0.27.0",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://action-layer.dev"
46
+ Repository = "https://github.com/grimjjow/actionlayer"
47
+
48
+ [project.scripts]
49
+ # The single entry point teammates install. `uvx actionlayer-mcp`
50
+ # starts the stdio MCP server.
51
+ actionlayer-mcp = "actionlayer_mcp.server:main"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["actionlayer_mcp"]