actionlayer 0.7.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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: actionlayer
3
+ Version: 0.7.0
4
+ Summary: MCP server for ActionLayer — give Claude Code and other MCP hosts access to ActionLayer's browser-action catalog with per-session credential intake.
5
+ Project-URL: Homepage, https://actionlayer.io
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
24
+
25
+ <!-- mcp-name: io.actionlayer/actionlayer -->
26
+
27
+ MCP server for [ActionLayer](https://actionlayer.io) — let Claude Code (and any MCP host) drive browser-based sites on your behalf.
28
+
29
+ > Previously published as `actionlayer-mcp`. That project is no longer
30
+ > maintained and stopped at 0.6.0 — install `actionlayer` instead.
31
+
32
+ ## Install
33
+
34
+ Get an API key from your ActionLayer dashboard at https://actionlayer.io, then:
35
+
36
+ ```bash
37
+ claude mcp add --scope user actionlayer "uvx" "actionlayer" \
38
+ --env ACTIONLAYER_API_KEY="ak_…" \
39
+ --env ACTIONLAYER_API_URL="https://api.actionlayer.io"
40
+ ```
41
+
42
+ Restart Claude Code and ask it to do something for you.
43
+
44
+ ## Usage from other MCP hosts
45
+
46
+ ```bash
47
+ actionlayer
48
+ ```
49
+
50
+ Works with Cursor, Cody, Codex, etc.
51
+
52
+ Env vars:
53
+ - `ACTIONLAYER_API_KEY` — required
54
+ - `ACTIONLAYER_API_URL` — defaults to `https://api.actionlayer.io`
55
+
56
+ ## Changelog
57
+
58
+ ### 0.7.0
59
+
60
+ Renamed: published as `actionlayer` on PyPI from this release on
61
+ (`actionlayer-mcp` is orphaned and frozen at 0.6.0). No code changes.
62
+ The command is now `actionlayer`; `actionlayer-mcp` remains as an
63
+ alias. Includes everything from the unreleased 0.6.1.
64
+
65
+ ### 0.6.1
66
+
67
+ No code changes. Embeds the MCP Registry `mcp-name` ownership token
68
+ so the package can be listed at registry.modelcontextprotocol.io.
69
+
70
+ ### 0.4.2
71
+
72
+ Internal taxonomy scrub. No behavior change; tool descriptions and
73
+ `next_action.prompt` fallbacks rephrased so calling agents don't
74
+ surface ActionLayer's internal terminology in their own user-facing
75
+ text.
76
+
77
+ ### 0.4.1
78
+
79
+ Payment-card approval flow (Phase 2 of the agent payments plan).
80
+
81
+ - `actionlayer_reply` now accepts `payment_approved: bool` for
82
+ tickets whose info_request includes `payment_status`. Users
83
+ approve a charge against their card-on-file with one call.
84
+ - `actionlayer_get_task` / `actionlayer_next_action` surface
85
+ `card_brand`, `card_last4`, `amount_usd`, and `payment_status`
86
+ on the info_request payload so an agent can prompt the user
87
+ with "Approve $X.XX charge on your <brand> card ending <last4>?"
88
+ - The card PAN itself NEVER crosses the MCP boundary. The charge
89
+ is completed against the user's card on file; the PAN never
90
+ appears on agent-visible surfaces. This is by design per
91
+ `docs/agent_payments_plan.md`.
92
+
93
+ ### 0.4.0
94
+
95
+ **Backwards-incompatible — public API taxonomy changed.**
96
+
97
+ - Ticket `state` collapses the in-flight phases into a single
98
+ public value `pending`. Polling code that branched on the
99
+ individual sub-phases (e.g. `if state == "executing"`) must
100
+ switch to `state == "pending"`. Terminal states (`completed`,
101
+ `failed`, `cancelled`) and `blocked_on_user` are unchanged.
102
+ - `live_view_url` is no longer returned by the public tools
103
+ (`actionlayer_invoke_action`, `actionlayer_get_action_ticket`).
104
+ User-facing callers should drive the task via polling
105
+ `actionlayer_get_action_ticket` and responding to
106
+ `blocked_on_user` prompts.
107
+ - `actionlayer_invoke_action` sync response now uses
108
+ `outcome="queued"` (instead of `outcome="blocked"`) when the
109
+ task is taking longer than the sync window allows. Callers
110
+ handling `outcome` need to add a `queued` branch: poll
111
+ `actionlayer_get_action_ticket` for terminal state.
112
+ - Terminal tickets now carry a free-text `reason` field summarising
113
+ the outcome (derived from `result.summary` on completed, `error`
114
+ on failed). Pre-existing `result` / `error` fields preserved
115
+ for callers that want the raw structured payload.
116
+
117
+ ### 0.3.1
118
+ - Update default API URL to `https://api.actionlayer.io`
119
+ (`api.action-layer.dev` still redirects, but the new host is the
120
+ current canonical name).
121
+
122
+ ### 0.3.0
123
+ - Browser-only catalog. Per-session credential intake.
124
+
125
+ ### 0.2.0
126
+ - Async-by-default dispatch for browser actions + ticket polling tool.
127
+
128
+ ### 0.1.0
129
+ - Initial release.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,7 @@
1
+ actionlayer_mcp/__init__.py,sha256=zqVf1Ww5J2y73Ni2aoOYywLAwmsK_7mRfnZx-U2XYaA,625
2
+ actionlayer_mcp/client.py,sha256=R81e89aq0-xWZcH_bYAUhk0M7sllyU3naQ4E-MbWGqg,11662
3
+ actionlayer_mcp/server.py,sha256=xKmUsy9XjhURknS2mxvPD7wYxbdfd6mWrbOTvVWD2Ws,46130
4
+ actionlayer-0.7.0.dist-info/METADATA,sha256=p7I_ymyxk0DBCnCxk99mafANHLpdcxCemmqkWQ_xhmA,4728
5
+ actionlayer-0.7.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ actionlayer-0.7.0.dist-info/entry_points.txt,sha256=Y8QmcYfYbimW9Um0yhcnL4u0MjQiKSeICPHsOo_i4F8,106
7
+ actionlayer-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ actionlayer = actionlayer_mcp.server:main
3
+ actionlayer-mcp = actionlayer_mcp.server:main
@@ -0,0 +1,18 @@
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` (formerly `actionlayer-mcp`,
9
+ orphaned at 0.6.0). The console_scripts entry point lets agent hosts
10
+ run it as `uvx actionlayer` or `pipx run actionlayer` with no clone
11
+ needed.
12
+ """
13
+
14
+ from actionlayer_mcp.client import ActionLayerClient, ApiError
15
+
16
+ __version__ = "0.7.0"
17
+
18
+ __all__ = ["ActionLayerClient", "ApiError", "__version__"]
@@ -0,0 +1,317 @@
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.actionlayer.io"
20
+ USER_AGENT = "actionlayer-mcp/0.7.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 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
+ target_url: Optional[str] = None,
75
+ credentials_source: Optional[str] = None,
76
+ ) -> dict[str, Any]:
77
+ # An auto-generated idempotency key gives the API a stable replay
78
+ # surface, but each call still gets a fresh ticket — true
79
+ # deduplication requires the agent to pass its own key, which is
80
+ # the right design (only the agent knows whether two same-arg
81
+ # calls are "retry" or "do it again").
82
+ # Same `al-` prefix as the SDK so a retry that crosses the
83
+ # SDK<->MCP shim boundary still de-dupes on the server side.
84
+ # SEC-MCP-010.
85
+ key = idempotency_key or f"al-{uuid.uuid4()}"
86
+ body: dict[str, Any] = {"goal": goal}
87
+ if max_budget_usd is not None:
88
+ body["max_budget_usd"] = max_budget_usd
89
+ if webhook_url is not None:
90
+ body["webhook_url"] = webhook_url
91
+ if target_url is not None:
92
+ body["target_url"] = target_url
93
+ if credentials_source is not None:
94
+ body["credentials_source"] = credentials_source
95
+ return await self._post("/tasks", json=body, headers={"Idempotency-Key": key})
96
+
97
+ async def get_task(self, ticket_id: str) -> dict[str, Any]:
98
+ return await self._get(f"/tasks/{ticket_id}")
99
+
100
+ async def reply_task(
101
+ self,
102
+ ticket_id: str,
103
+ *,
104
+ message: Optional[str] = None,
105
+ field: Optional[str] = None,
106
+ value: Optional[str] = None,
107
+ sensitive: bool = False,
108
+ info: Optional[dict[str, Any]] = None,
109
+ ) -> dict[str, Any]:
110
+ """Forward a reply to /tasks/{id}/reply.
111
+
112
+ `info` is the info-required payload — pass-through to the
113
+ `info` field on ReplyTaskRequest. Required when the ticket
114
+ carries an `info_request` (set when ActionLayer paused to collect info from the user);
115
+ the API 422s otherwise.
116
+ """
117
+ body: dict[str, Any] = {"sensitive": sensitive}
118
+ if message is not None:
119
+ body["message"] = message
120
+ if field is not None:
121
+ body["field"] = field
122
+ if value is not None:
123
+ body["value"] = value
124
+ if info is not None:
125
+ body["info"] = info
126
+ return await self._post(f"/tasks/{ticket_id}/reply", json=body)
127
+
128
+ async def cancel_task(self, ticket_id: str) -> dict[str, Any]:
129
+ return await self._post(f"/tasks/{ticket_id}/cancel", json=None)
130
+
131
+ async def submit_feedback(
132
+ self,
133
+ ticket_id: str,
134
+ *,
135
+ rating: str,
136
+ comment: Optional[str] = None,
137
+ ) -> dict[str, Any]:
138
+ """Leave feedback on a finished task. `rating` is "good"/"bad";
139
+ `comment` is an optional note. The API 409s unless the ticket is
140
+ completed or failed."""
141
+ body: dict[str, Any] = {"rating": rating}
142
+ if comment is not None:
143
+ body["comment"] = comment
144
+ return await self._post(f"/tasks/{ticket_id}/feedback", json=body)
145
+
146
+ # --- Skills -------------------------------------------------------
147
+
148
+ async def list_skills(
149
+ self, *, source_format: Optional[str] = None
150
+ ) -> list[dict[str, Any]]:
151
+ params: dict[str, Any] = {}
152
+ if source_format:
153
+ params["source_format"] = source_format
154
+ result = await self._get("/skills", params=params)
155
+ # /skills returns either a bare list or {"items": [...]} —
156
+ # accept either to stay forward-compatible.
157
+ if isinstance(result, dict) and "items" in result:
158
+ return result["items"]
159
+ return result # type: ignore[return-value]
160
+
161
+ # --- Actions (P10) ------------------------------------------------
162
+
163
+ async def list_actions(
164
+ self,
165
+ *,
166
+ provider: Optional[str] = None,
167
+ category: Optional[str] = None,
168
+ ) -> dict[str, Any]:
169
+ """GET /v1/actions — returns the full catalog response shape
170
+ ({count, actions, providers, categories}). The MCP server
171
+ decides what subset to surface to the agent host."""
172
+ params: dict[str, Any] = {}
173
+ if provider:
174
+ params["provider"] = provider
175
+ if category:
176
+ params["category"] = category
177
+ return await self._get("/v1/actions", params=params)
178
+
179
+ async def get_action(self, action_id: str) -> dict[str, Any]:
180
+ """GET /v1/actions/{id} — full ActionDefinition with the
181
+ input/output schemas."""
182
+ return await self._get(f"/v1/actions/{action_id}")
183
+
184
+ async def invoke_action(
185
+ self,
186
+ action_id: str,
187
+ *,
188
+ inputs: dict[str, Any],
189
+ executor: Optional[str] = None,
190
+ ) -> dict[str, Any]:
191
+ """POST /v1/actions/{id}.
192
+
193
+ Two response shapes depending on the action's executor:
194
+ * API actions (gmail, slack, github, ...): run sync. Outcome
195
+ is one of 'succeeded' | 'blocked' | 'failed' with the
196
+ output inline.
197
+ * Browser actions (resy, partiful, usps, ...): return 202
198
+ with outcome='queued' + payload.ticket_id + payload.poll_path.
199
+ Caller polls get_action_ticket(ticket_id) until is_terminal.
200
+
201
+ Same shape either way (ActionInvokeResponse). Caller branches
202
+ on outcome to decide whether to poll.
203
+ """
204
+ body: dict[str, Any] = {"inputs": inputs}
205
+ if executor:
206
+ body["executor"] = executor
207
+ return await self._post(f"/v1/actions/{action_id}", json=body)
208
+
209
+ async def get_action_ticket(self, ticket_id: str) -> dict[str, Any]:
210
+ """GET /v1/actions/tickets/{id} — poll an async action ticket.
211
+
212
+ Returns ActionTicketResponse: state, outcome, output, error,
213
+ reason, blocked_reason, is_terminal, creds_prompt. Poll every
214
+ 3-5s until is_terminal=True. When creds_prompt is set the
215
+ ticket is waiting for the user to submit credentials via
216
+ provide_credentials.
217
+ """
218
+ return await self._get(f"/v1/actions/tickets/{ticket_id}")
219
+
220
+ async def provide_credentials(
221
+ self,
222
+ ticket_id: str,
223
+ *,
224
+ fields: dict[str, str],
225
+ remember_for_session: bool = False,
226
+ ) -> dict[str, Any]:
227
+ """POST /v1/actions/tickets/{id}/credentials — submit creds
228
+ for a ticket blocked with creds_required.
229
+
230
+ Credentials are KMS-encrypted in transit + at rest in Redis
231
+ with a 15-minute TTL (single-use GETDEL by the worker). Never
232
+ written to Postgres or logs.
233
+ """
234
+ body: dict[str, Any] = {"fields": fields}
235
+ if remember_for_session:
236
+ body["remember_for_session"] = True
237
+ return await self._post(
238
+ f"/v1/actions/tickets/{ticket_id}/credentials", json=body
239
+ )
240
+
241
+ # --- Jobs (P6) ----------------------------------------------------
242
+
243
+ async def create_job(
244
+ self,
245
+ *,
246
+ goal: str,
247
+ budget_usd: Optional[float] = None,
248
+ deadline: Optional[str] = None,
249
+ webhook_url: Optional[str] = None,
250
+ ) -> dict[str, Any]:
251
+ """POST /v1/jobs — kick off a multi-step planner-driven job.
252
+
253
+ `deadline` is ISO 8601 (e.g. '2026-05-15T19:00:00-07:00').
254
+ Returns the job in 'planning' state; caller polls get_job.
255
+ """
256
+ body: dict[str, Any] = {"goal": goal}
257
+ if budget_usd is not None:
258
+ body["budget_usd"] = budget_usd
259
+ if deadline is not None:
260
+ body["deadline"] = deadline
261
+ if webhook_url is not None:
262
+ body["webhook_url"] = webhook_url
263
+ return await self._post("/v1/jobs", json=body)
264
+
265
+ async def get_job(self, job_id: str) -> dict[str, Any]:
266
+ """GET /v1/jobs/{id} — returns the full Job + items array."""
267
+ return await self._get(f"/v1/jobs/{job_id}")
268
+
269
+ async def cancel_job(self, job_id: str) -> dict[str, Any]:
270
+ return await self._post(f"/v1/jobs/{job_id}/cancel", json=None)
271
+
272
+ async def resume_job(self, job_id: str) -> dict[str, Any]:
273
+ return await self._post(f"/v1/jobs/{job_id}/resume", json=None)
274
+
275
+ # --- Internals ----------------------------------------------------
276
+
277
+ async def _get(
278
+ self, path: str, *, params: Optional[dict[str, Any]] = None
279
+ ) -> Any:
280
+ resp = await self._client.get(path, params=params)
281
+ return _parse(resp)
282
+
283
+ async def _post(
284
+ self,
285
+ path: str,
286
+ *,
287
+ json: Optional[dict[str, Any]],
288
+ headers: Optional[dict[str, str]] = None,
289
+ ) -> dict[str, Any]:
290
+ resp = await self._client.post(path, json=json, headers=headers)
291
+ return _parse(resp)
292
+
293
+
294
+ def _parse(resp: httpx.Response) -> Any:
295
+ if resp.is_success:
296
+ if not resp.content:
297
+ return {}
298
+ return resp.json()
299
+ detail: str
300
+ try:
301
+ body = resp.json()
302
+ detail = body.get("detail") if isinstance(body, dict) else str(body)
303
+ if not isinstance(detail, str):
304
+ detail = str(detail)
305
+ except Exception:
306
+ detail = resp.text or resp.reason_phrase
307
+ # SEC-MCP-011: for non-validation 5xx responses, collapse the
308
+ # server-emitted detail to a generic message. Validation 4xx is
309
+ # preserved -- the user / agent needs the specific reason to fix
310
+ # the call. A future verbose 500 path that leaks DB ids / file
311
+ # paths / cross-tenant data no longer surfaces through the SDK
312
+ # exception message.
313
+ if 500 <= resp.status_code < 600:
314
+ detail = "internal error -- check the ActionLayer status page"
315
+ raise ApiError(resp.status_code, detail)
316
+
317
+