authyouragent 0.3.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,397 @@
1
+ """Auth Your Agent, as an MCP server.
2
+
3
+ A thin client of the Auth Your Agent browser vault. The browser runs inside the
4
+ vault container; its debug port never leaves the container, so this server and
5
+ the agent that uses it cannot read the owner's cookies. The agent drives the
6
+ browser only through these tools, and the owner can take over from their phone.
7
+
8
+ {"mcpServers": {"authyouragent": {
9
+ "command": "authyouragent-mcp",
10
+ "env": {"AYA_AGENT_ID": "ag_...", "AYA_KEY_FILE": "/path/agent.pem",
11
+ "AYA_VAULT_URL": "http://127.0.0.1:7801",
12
+ "AYA_VAULT_TOKEN_FILE": "/path/vault.token"}}}}
13
+
14
+ Environment:
15
+ AYA_AGENT_ID agent id from the Auth Your Agent app (required)
16
+ AYA_KEY_FILE the agent's private key, PEM (required)
17
+ AYA_CLOUD default https://authyouragent.com
18
+ AYA_VAULT_URL the vault broker (default http://127.0.0.1:7801)
19
+ AYA_VAULT_TOKEN_FILE file holding the vault's bearer token (or AYA_VAULT_TOKEN)
20
+
21
+ Tools: navigate, click, type_text, read_page, check_login_wall, request_takeover,
22
+ wait_for_takeover, request_approval, end_session, check_agent_status,
23
+ report_site.
24
+
25
+ The vault ends the session by itself (real sign-out, then the browser profile is
26
+ destroyed) if this server stops sending heartbeats or the owner revokes the agent.
27
+ """
28
+ import asyncio
29
+ import os
30
+ import re
31
+ import sys
32
+ import time
33
+
34
+ import httpx
35
+
36
+ try: # mcp 2.x
37
+ from mcp.server.mcpserver import MCPServer as FastMCP, Context
38
+ except ImportError: # mcp 1.x
39
+ from mcp.server.fastmcp import FastMCP, Context
40
+
41
+ from .agent import AgentClient
42
+
43
+ DEFAULT_CLOUD = "https://authyouragent.com"
44
+ WAIT_DEFAULT = 240 # stay under common MCP client call timeouts (300s)
45
+ WAIT_MAX = 290
46
+ HEARTBEAT_S = 20
47
+
48
+ mcp = FastMCP("authyouragent")
49
+ _agent = None
50
+ _vault = None
51
+ _hb = None
52
+
53
+
54
+ def _log(*a):
55
+ print("[authyouragent-mcp]", *a, file=sys.stderr, flush=True)
56
+
57
+
58
+ def _get_agent():
59
+ global _agent
60
+ if _agent is None:
61
+ aid, kf = os.environ.get("AYA_AGENT_ID"), os.environ.get("AYA_KEY_FILE")
62
+ if not aid or not kf:
63
+ raise RuntimeError("set AYA_AGENT_ID and AYA_KEY_FILE in this MCP server's env")
64
+ _agent = AgentClient(base_url=os.environ.get("AYA_CLOUD", DEFAULT_CLOUD),
65
+ agent_id=aid, privkey_pem=open(os.path.expanduser(kf)).read(),
66
+ verify=not os.environ.get("AYA_INSECURE"))
67
+ return _agent
68
+
69
+
70
+ def _vault_client():
71
+ global _vault
72
+ if _vault is None:
73
+ token = os.environ.get("AYA_VAULT_TOKEN", "")
74
+ tf = os.environ.get("AYA_VAULT_TOKEN_FILE")
75
+ if tf:
76
+ token = open(os.path.expanduser(tf)).read().strip()
77
+ if not token:
78
+ default = os.path.expanduser("~/.authyouragent/vault/token")
79
+ if os.path.exists(default):
80
+ token = open(default).read().strip()
81
+ if not token:
82
+ raise RuntimeError("no vault token: run `authyouragent vault up`, or set AYA_VAULT_TOKEN_FILE")
83
+ _vault = httpx.AsyncClient(base_url=os.environ.get("AYA_VAULT_URL", "http://127.0.0.1:7801"),
84
+ headers={"Authorization": f"Bearer {token}"},
85
+ timeout=httpx.Timeout(60, read=WAIT_MAX + 30))
86
+ return _vault
87
+
88
+
89
+ async def _heartbeat():
90
+ """Keep the vault's lease alive while this server runs. If this process
91
+ dies the heartbeats stop and the vault ends the session by itself."""
92
+ while True:
93
+ try:
94
+ await _vault_client().post("/ping")
95
+ except Exception:
96
+ pass
97
+ await asyncio.sleep(HEARTBEAT_S)
98
+
99
+
100
+ async def _call(method, path, **kw):
101
+ global _hb
102
+ if _hb is None or _hb.done():
103
+ _hb = asyncio.create_task(_heartbeat())
104
+ try:
105
+ r = await _vault_client().request(method, path, **kw)
106
+ except httpx.HTTPError as e:
107
+ raise RuntimeError(f"cannot reach the browser vault ({type(e).__name__}); is it running?")
108
+ data = r.json()
109
+ if r.status_code == 409:
110
+ raise RuntimeError(data.get("detail", "busy"))
111
+ if r.status_code >= 400:
112
+ raise RuntimeError(f"vault error {r.status_code}: {data.get('detail') or data.get('error')}")
113
+ return data
114
+
115
+
116
+ def _where(d):
117
+ return f"{d['url']} ({d['title']})" if d.get("title") else d.get("url", "")
118
+
119
+
120
+ def _err(e):
121
+ return f"error: {e}"
122
+
123
+
124
+ # ─── Browser tools ────────────────────────────────────────────────────────
125
+
126
+ @mcp.tool()
127
+ async def navigate(url: str) -> str:
128
+ """Open a URL in the browser.
129
+ url: the full URL, e.g. "https://example.com"."""
130
+ try:
131
+ return f"page: {_where(await _call('POST', '/navigate', json={'url': url}))}"
132
+ except Exception as e:
133
+ return _err(e)
134
+
135
+
136
+ @mcp.tool()
137
+ async def click(selector: str) -> str:
138
+ """Click an element on the page.
139
+ selector: a CSS or Playwright selector, e.g. "button[type=submit]" or "text=Add Server"."""
140
+ try:
141
+ return f"clicked: {selector}\npage: {_where(await _call('POST', '/click', json={'selector': selector}))}"
142
+ except Exception as e:
143
+ return _err(e)
144
+
145
+
146
+ @mcp.tool()
147
+ async def type_text(selector: str, text: str, submit: bool = False) -> str:
148
+ """Type into a form field (replaces its value). Never use this for the owner's
149
+ passwords or codes: ask for a take over instead.
150
+ selector: the field, e.g. "input[name=url]".
151
+ text: what to type.
152
+ submit: press Enter afterwards."""
153
+ try:
154
+ d = await _call('POST', '/type', json={'selector': selector, 'text': text, 'submit': submit})
155
+ return f"typed into: {selector}\npage: {_where(d)}"
156
+ except Exception as e:
157
+ return _err(e)
158
+
159
+
160
+ @mcp.tool()
161
+ async def read_page(max_chars: int = 5000) -> str:
162
+ """Read the page's URL, title and visible text (no screenshot needed)."""
163
+ try:
164
+ d = await _call('GET', '/read', params={'max_chars': max_chars})
165
+ return f"url: {d['url']}\ntitle: {d['title']}\n\n{d['text']}"
166
+ except Exception as e:
167
+ return _err(e)
168
+
169
+
170
+ @mcp.tool()
171
+ async def check_login_wall() -> str:
172
+ """Check whether the page is stuck at a sign-in, one-time code (2FA) or sign-in
173
+ approval step. Reads form fields and page text only."""
174
+ try:
175
+ d = await _call('GET', '/login_wall')
176
+ except Exception as e:
177
+ return _err(e)
178
+ if d["blocked"]:
179
+ return (f"blocked: yes\npage: {_where(d)}\nThe page asks for a password, a one-time code "
180
+ f"or a sign-in approval. Call request_takeover so the owner can do it.")
181
+ return f"blocked: no\npage: {_where(d)}"
182
+
183
+
184
+ NEXT = {
185
+ "done": "The owner finished and handed the browser back. Continue your task on the "
186
+ "signed-in page. When you are finished with the site, call end_session.",
187
+ "cancelled": "The owner declined. Do not retry right away; tell them what you needed.",
188
+ "expired": "Nobody took over within 10 minutes. Tell the owner, or try again later.",
189
+ "agent_left": "The connection to the browser was lost during the take over. Try again.",
190
+ "incomplete": "The owner pressed Done but the page still asks for a password, code or "
191
+ "approval. Ask them what happened before trying again.",
192
+ }
193
+
194
+
195
+ async def _wait(jid, wait_seconds, ctx):
196
+ deadline = time.time() + max(5, min(int(wait_seconds or WAIT_DEFAULT), WAIT_MAX))
197
+ while True:
198
+ step = int(max(1, min(30, deadline - time.time())))
199
+ d = await _call('GET', f'/takeover/{jid}', params={'wait': step})
200
+ if d["state"] == "finished":
201
+ return (f"result: {d['result']}\nafter: {d['after']}s\npage: {d['url']}\n"
202
+ f"{NEXT.get(d['result'], '')}")
203
+ if d["state"] in ("error", "unknown"):
204
+ return f"result: error\n{d.get('detail', 'no open take over with that id')}"
205
+ if ctx is not None:
206
+ try:
207
+ await ctx.report_progress(d.get("after", 0), None)
208
+ except Exception:
209
+ pass
210
+ if time.time() >= deadline:
211
+ return (f"result: waiting\ntakeover_id: {jid}\nThe owner has not finished yet "
212
+ f"({d.get('after', 0)}s so far). Call wait_for_takeover with this takeover_id. "
213
+ f"Do not use the browser meanwhile.")
214
+
215
+
216
+ @mcp.tool()
217
+ async def request_takeover(reason: str, wait_seconds: int = WAIT_DEFAULT, ctx: Context = None) -> str:
218
+ """Ask your owner to take over the browser from their phone when you are stuck at a
219
+ login, 2FA code, CAPTCHA or anything only they can do. You are disconnected from the
220
+ browser while they are in control, and you never see what they type.
221
+ Returns result: done / cancelled / expired / incomplete, or result: waiting with a
222
+ takeover_id for wait_for_takeover.
223
+ reason: one short line shown to the owner, e.g. "Please sign in to example.com"."""
224
+ try:
225
+ d = await _call('POST', '/takeover', json={'reason': reason[:200]})
226
+ _log("take over requested:", reason)
227
+ return await _wait(d["takeover_id"], wait_seconds, ctx)
228
+ except Exception as e:
229
+ return _err(e)
230
+
231
+
232
+ @mcp.tool()
233
+ async def wait_for_takeover(takeover_id: str, wait_seconds: int = WAIT_DEFAULT,
234
+ ctx: Context = None) -> str:
235
+ """Keep waiting for a take over that request_takeover reported as still waiting."""
236
+ try:
237
+ return await _wait(takeover_id, wait_seconds, ctx)
238
+ except Exception as e:
239
+ return _err(e)
240
+
241
+
242
+ @mcp.tool()
243
+ async def end_session() -> str:
244
+ """End the owner's session when you are finished with the web: the vault signs out
245
+ of the sites used, then destroys the browser profile. Always call this when done.
246
+ (The vault also does it by itself if you stop running or the owner revokes you.)"""
247
+ try:
248
+ d = await _call('POST', '/end_session', json={'why': 'agent finished'})
249
+ except Exception as e:
250
+ return _err(e)
251
+ lines = [f" {site}: {res}" for site, res in d.get("sign_out", {}).items()]
252
+ return "ended\nBrowser profile destroyed." + ("\nSign-out:\n" + "\n".join(lines) if lines else "")
253
+
254
+
255
+ # ─── Owner / platform tools ───────────────────────────────────────────────
256
+
257
+ @mcp.tool()
258
+ async def request_approval(site: str, action: str, wait_seconds: int = 120,
259
+ ctx: Context = None) -> str:
260
+ """Ask your owner to approve a sensitive action before you perform it.
261
+ The owner receives a push notification on their phone showing the site and action.
262
+ They approve or deny. You should not perform the action until this returns approved.
263
+ Use this before deleting data, changing settings, making purchases, modifying
264
+ permissions, or any action that could have irreversible consequences.
265
+ site: the domain or URL of the site where the action will be performed, e.g. "github.com".
266
+ action: a short label for the action, e.g. "delete repository", "change password",
267
+ "purchase subscription".
268
+ wait_seconds: how long to wait for the owner's response (default 120, max 290).
269
+ Returns: "approved" or "denied" or "expired" (owner did not respond in time)."""
270
+ agent = _get_agent()
271
+ action_norm = re.sub(r"[^a-z0-9_ :-]", "", action.lower()).strip().replace(" ", "_")[:64] or "action"
272
+ site_norm = re.sub(r"^https?://", "", site).split("/")[0].lower()[:200]
273
+
274
+ try:
275
+ r = await asyncio.to_thread(
276
+ agent.client.post,
277
+ f"{agent.base}/api/v1/agent-approval",
278
+ json={"agent_id": agent.agent_id, "agent_jwt": agent._agent_jwt(),
279
+ "site": site_norm, "action": action_norm},
280
+ timeout=15)
281
+ if r.status_code != 200:
282
+ return f"error: server returned {r.status_code}: {r.text[:200]}"
283
+ data = r.json()
284
+ txn_id = data.get("txn_id")
285
+ _log("approval requested:", site_norm, action_norm, "txn:", txn_id)
286
+ except Exception as e:
287
+ return f"error: {type(e).__name__}: {e}"
288
+
289
+ deadline = time.time() + min(wait_seconds, WAIT_MAX)
290
+ while time.time() < deadline:
291
+ await asyncio.sleep(3)
292
+ try:
293
+ r = await asyncio.to_thread(
294
+ agent.client.get,
295
+ f"{agent.base}/api/v1/authz-requests/{txn_id}",
296
+ timeout=10)
297
+ if r.status_code == 200:
298
+ status = r.json().get("status")
299
+ if status in ("approved", "denied", "expired", "cancelled"):
300
+ _log("approval result:", status)
301
+ if status == "approved":
302
+ return (f"approved\nThe owner approved the action '{action_norm}' "
303
+ f"on {site_norm}. You may proceed.")
304
+ elif status == "denied":
305
+ return (f"denied\nThe owner denied the action '{action_norm}' "
306
+ f"on {site_norm}. Do not proceed.")
307
+ elif status == "expired":
308
+ return (f"expired\nThe owner did not respond in time for "
309
+ f"'{action_norm}' on {site_norm}.")
310
+ else:
311
+ return "cancelled\nThe approval was cancelled."
312
+ except Exception:
313
+ pass
314
+ if ctx is not None:
315
+ try:
316
+ await ctx.report_progress(time.time() - (deadline - min(wait_seconds, WAIT_MAX)),
317
+ None)
318
+ except Exception:
319
+ pass
320
+
321
+ return (f"expired\nThe owner did not respond in time for '{action_norm}' "
322
+ f"on {site_norm}.")
323
+
324
+
325
+ @mcp.tool()
326
+ async def check_agent_status() -> str:
327
+ """Check whether your agent is still authorized by the owner. The owner can revoke
328
+ your access at any time from their phone. Call this periodically (e.g. before
329
+ starting a new task) to verify you are still active.
330
+ Returns: active / revoked / error."""
331
+ agent = _get_agent()
332
+ try:
333
+ r = await asyncio.to_thread(
334
+ agent.client.get,
335
+ f"{agent.base}/api/v1/agent-status",
336
+ headers={"X-Agent-JWT": agent._agent_jwt()},
337
+ params={"agent_id": agent.agent_id},
338
+ timeout=10)
339
+ if r.status_code == 200:
340
+ data = r.json()
341
+ status = data.get("status", "active")
342
+ if status == "revoked":
343
+ return ("revoked\nThe owner has revoked your access. Stop all work "
344
+ "immediately and inform the owner.")
345
+ revoked_sites = data.get("revoked_sites", [])
346
+ return (f"active\nYou are authorized. Agent: {data.get('agent_name','?')}"
347
+ + (f"\nRevoked sites: {', '.join(revoked_sites)}" if revoked_sites else ""))
348
+ elif r.status_code == 403:
349
+ return "revoked\nThe owner has revoked your access. Stop all work immediately."
350
+ elif r.status_code == 401:
351
+ return "error: authentication failed (invalid agent JWT)"
352
+ return f"error: server returned {r.status_code}"
353
+ except Exception as e:
354
+ return f"error: {type(e).__name__}: {e}"
355
+
356
+
357
+ @mcp.tool()
358
+ async def report_site(site: str, kind: str, detail: str = "", url: str = "") -> str:
359
+ """Report a site where take over did not work correctly, so the team can improve it.
360
+ Call this whenever check_login_wall misses a login, takeover fails to complete,
361
+ the handback does not trigger, or anything else goes wrong on a real site.
362
+ site: the domain or URL, e.g. "reddit.com".
363
+ kind: one of: shadow_dom, bot_detection, input_not_detected, takeover_failed,
364
+ handback_failed, session_not_cleared, oauth_blocked, other.
365
+ detail: one short line describing what happened (optional).
366
+ url: the full page URL if known (optional)."""
367
+ kinds = ("shadow_dom", "bot_detection", "input_not_detected",
368
+ "takeover_failed", "handback_failed", "session_not_cleared", "oauth_blocked", "other")
369
+ if kind not in kinds:
370
+ return f"error: kind must be one of {', '.join(kinds)}"
371
+ agent = _get_agent()
372
+ try:
373
+ r = await asyncio.to_thread(
374
+ agent.client.post,
375
+ f"{agent.base}/api/v1/takeover/report",
376
+ json={"agent_id": agent.agent_id, "agent_jwt": agent._agent_jwt(),
377
+ "site": site[:200], "kind": kind, "detail": detail[:280],
378
+ "url": url[:500]})
379
+ if r.status_code == 200:
380
+ _log("site reported:", site, kind)
381
+ return f"reported: {site} ({kind})\nThank you. The team will look into it."
382
+ return f"error: server returned {r.status_code}: {r.text[:200]}"
383
+ except Exception as e:
384
+ return f"error: {type(e).__name__}: {e}"
385
+
386
+
387
+ def main():
388
+ import argparse
389
+ ap = argparse.ArgumentParser(prog="authyouragent-mcp",
390
+ description="Auth Your Agent MCP server (browser vault client)")
391
+ ap.add_argument("--transport", choices=("stdio",), default="stdio")
392
+ ap.parse_args()
393
+ mcp.run("stdio")
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()