webtap-tool 0.11.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.
Files changed (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,220 @@
1
+ """Chrome browser connection management commands."""
2
+
3
+ from replkit2.types import ExecutionContext
4
+
5
+ from webtap.app import app
6
+ from webtap.client import RPCError
7
+ from webtap.commands._builders import info_response, table_response, error_response
8
+ from webtap.commands._tips import get_mcp_description, get_tips
9
+
10
+ _connect_desc = get_mcp_description("connect")
11
+ _disconnect_desc = get_mcp_description("disconnect")
12
+ _clear_desc = get_mcp_description("clear")
13
+
14
+ # Truncation values for pages() REPL mode (compact display)
15
+ _PAGES_REPL_TRUNCATE = {
16
+ "Title": {"max": 20, "mode": "end"},
17
+ "URL": {"max": 30, "mode": "middle"},
18
+ "ID": {"max": 6, "mode": "end"},
19
+ }
20
+
21
+ # Truncation values for pages() MCP mode (generous for LLM context)
22
+ _PAGES_MCP_TRUNCATE = {
23
+ "Title": {"max": 100, "mode": "end"},
24
+ "URL": {"max": 200, "mode": "middle"},
25
+ "ID": {"max": 50, "mode": "end"},
26
+ }
27
+
28
+
29
+ @app.command(
30
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _connect_desc or ""}
31
+ )
32
+ def connect(state, page: int = None, page_id: str = None) -> dict: # pyright: ignore[reportArgumentType]
33
+ """Connect to Chrome page and enable all required domains.
34
+
35
+ Args:
36
+ page: Connect by page index (0-based)
37
+ page_id: Connect by page ID
38
+
39
+ Note: If neither is specified, connects to first available page.
40
+ Cannot specify both page and page_id.
41
+
42
+ Examples:
43
+ connect() # First page
44
+ connect(page=2) # Third page (0-indexed)
45
+ connect(page_id="xyz") # Specific page ID
46
+
47
+ Returns:
48
+ Connection status in markdown
49
+ """
50
+ try:
51
+ # Build params - default to page=0 when no params given
52
+ params = {}
53
+ if page is not None:
54
+ params["page"] = page
55
+ if page_id is not None:
56
+ params["page_id"] = page_id
57
+ if not params:
58
+ params["page"] = 0 # Connect to first page by default
59
+
60
+ result = state.client.call("connect", **params)
61
+ except RPCError as e:
62
+ return error_response(e.message)
63
+ except Exception as e:
64
+ return error_response(str(e))
65
+
66
+ # Success - return formatted info with full URL
67
+ return info_response(
68
+ title="Connection Established",
69
+ fields={"Page": result.get("title", "Unknown"), "URL": result.get("url", "")},
70
+ )
71
+
72
+
73
+ @app.command(
74
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _disconnect_desc or ""}
75
+ )
76
+ def disconnect(state) -> dict:
77
+ """Disconnect from Chrome."""
78
+ try:
79
+ state.client.call("disconnect")
80
+ except RPCError as e:
81
+ # INVALID_STATE means not connected
82
+ if e.code == "INVALID_STATE":
83
+ return info_response(title="Disconnect Status", fields={"Status": "Not connected"})
84
+ return error_response(e.message)
85
+ except Exception as e:
86
+ return error_response(str(e))
87
+
88
+ return info_response(title="Disconnect Status", fields={"Status": "Disconnected"})
89
+
90
+
91
+ @app.command(
92
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _clear_desc or ""}
93
+ )
94
+ def clear(state, events: bool = True, console: bool = False) -> dict:
95
+ """Clear various data stores.
96
+
97
+ Args:
98
+ events: Clear CDP events (default: True)
99
+ console: Clear console messages (default: False)
100
+
101
+ Examples:
102
+ clear() # Clear events only
103
+ clear(events=True, console=True) # Clear events and console
104
+ clear(events=False, console=True) # Console only
105
+
106
+ Returns:
107
+ Summary of what was cleared
108
+ """
109
+ try:
110
+ result = state.client.call("clear", events=events, console=console)
111
+ except RPCError as e:
112
+ return error_response(e.message)
113
+ except Exception as e:
114
+ return error_response(str(e))
115
+
116
+ # Build cleared list from result
117
+ cleared = result.get("cleared", [])
118
+
119
+ if not cleared:
120
+ return info_response(
121
+ title="Clear Status",
122
+ fields={"Result": "Nothing to clear (specify events=True or console=True)"},
123
+ )
124
+
125
+ return info_response(title="Clear Status", fields={"Cleared": ", ".join(cleared)})
126
+
127
+
128
+ @app.command(
129
+ display="markdown",
130
+ fastmcp={"type": "resource", "mime_type": "text/markdown"},
131
+ )
132
+ def pages(state, _ctx: ExecutionContext = None) -> dict: # pyright: ignore[reportArgumentType]
133
+ """List available Chrome pages.
134
+
135
+ Returns:
136
+ Table of available pages in markdown
137
+ """
138
+ try:
139
+ result = state.client.call("pages")
140
+ pages_list = result.get("pages", [])
141
+ except RPCError as e:
142
+ return error_response(e.message)
143
+ except Exception as e:
144
+ return error_response(str(e))
145
+
146
+ # Format rows for table with FULL data
147
+ rows = [
148
+ {
149
+ "Index": str(i),
150
+ "Title": p.get("title", "Untitled"), # Full title
151
+ "URL": p.get("url", ""), # Full URL
152
+ "ID": p.get("id", ""), # Full ID
153
+ "Connected": "Yes" if p.get("is_connected") else "No",
154
+ }
155
+ for i, p in enumerate(pages_list)
156
+ ]
157
+
158
+ # Get contextual tips
159
+ tips = None
160
+ if rows:
161
+ # Find connected page or first page
162
+ connected_row = next((r for r in rows if r["Connected"] == "Yes"), rows[0])
163
+ page_index = connected_row["Index"]
164
+
165
+ # Get page_id for the example page
166
+ connected_page = next((p for p in pages_list if str(pages_list.index(p)) == page_index), None)
167
+ page_id = connected_page.get("id", "")[:6] if connected_page else ""
168
+
169
+ tips = get_tips("pages", context={"index": page_index, "page_id": page_id})
170
+
171
+ # Build contextual warnings
172
+ warnings = []
173
+ if any(r["Connected"] == "Yes" for r in rows):
174
+ warnings.append("Already connected - call connect(page=N) to switch pages")
175
+
176
+ # Use mode-specific truncation
177
+ is_repl = _ctx and _ctx.is_repl()
178
+ truncate = _PAGES_REPL_TRUNCATE if is_repl else _PAGES_MCP_TRUNCATE
179
+
180
+ # Build markdown response
181
+ return table_response(
182
+ title="Chrome Pages",
183
+ headers=["Index", "Title", "URL", "ID", "Connected"],
184
+ rows=rows,
185
+ summary=f"{len(pages_list)} page{'s' if len(pages_list) != 1 else ''} available",
186
+ warnings=warnings if warnings else None,
187
+ tips=tips,
188
+ truncate=truncate,
189
+ )
190
+
191
+
192
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "text/markdown"})
193
+ def status(state) -> dict:
194
+ """Get connection status.
195
+
196
+ Returns:
197
+ Status information in markdown
198
+ """
199
+ try:
200
+ status_data = state.client.call("status")
201
+ except RPCError as e:
202
+ return error_response(e.message)
203
+ except Exception as e:
204
+ return error_response(str(e))
205
+
206
+ # Check if connected
207
+ if not status_data.get("connected"):
208
+ return error_response("Not connected to any page. Use connect() first.")
209
+
210
+ # Build formatted response with full URL
211
+ page = status_data.get("page", {})
212
+ return info_response(
213
+ title="Connection Status",
214
+ fields={
215
+ "Page": page.get("title", "Unknown"),
216
+ "URL": page.get("url", ""),
217
+ "Events": f"{status_data.get('events', {}).get('total', 0)} stored",
218
+ "Fetch": "Enabled" if status_data.get("fetch", {}).get("enabled") else "Disabled",
219
+ },
220
+ )
@@ -0,0 +1,87 @@
1
+ """Browser console message monitoring and display commands."""
2
+
3
+ from replkit2.types import ExecutionContext
4
+
5
+ from webtap.app import app
6
+ from webtap.client import RPCError
7
+ from webtap.commands._builders import table_response, error_response, format_timestamp
8
+ from webtap.commands._tips import get_tips
9
+
10
+ # Truncation values for REPL mode (compact display)
11
+ _REPL_TRUNCATE = {
12
+ "Message": {"max": 80, "mode": "end"},
13
+ }
14
+
15
+ # Truncation values for MCP mode (generous for LLM context)
16
+ _MCP_TRUNCATE = {
17
+ "Message": {"max": 300, "mode": "end"},
18
+ }
19
+
20
+
21
+ @app.command(
22
+ display="markdown",
23
+ fastmcp={"type": "resource", "mime_type": "text/markdown"},
24
+ )
25
+ def console(state, limit: int = 50, _ctx: ExecutionContext = None) -> dict: # pyright: ignore[reportArgumentType]
26
+ """Show console messages with full data.
27
+
28
+ Args:
29
+ limit: Max results (default: 50)
30
+
31
+ Examples:
32
+ console() # Recent console messages
33
+ console(limit=100) # Show more messages
34
+
35
+ Returns:
36
+ Table of console messages with full data
37
+ """
38
+ # Get console messages via RPC
39
+ try:
40
+ result = state.client.call("console", limit=limit)
41
+ messages = result.get("messages", [])
42
+ except RPCError as e:
43
+ return error_response(e.message)
44
+ except Exception as e:
45
+ return error_response(str(e))
46
+
47
+ # Mode-specific configuration
48
+ is_repl = _ctx and _ctx.is_repl()
49
+
50
+ # Build rows with mode-specific formatting
51
+ rows = [
52
+ {
53
+ "ID": str(m.get("id", i)),
54
+ "Level": m.get("level", "unknown"),
55
+ "Source": m.get("source", ""),
56
+ "Message": m.get("message", ""),
57
+ # REPL: human-friendly time, MCP: raw timestamp for LLM
58
+ "Time": format_timestamp(m.get("timestamp")) if is_repl else (m.get("timestamp") or 0),
59
+ }
60
+ for i, m in enumerate(messages)
61
+ ]
62
+
63
+ # Build response
64
+ warnings = []
65
+ if limit and len(messages) == limit:
66
+ warnings.append(f"Showing first {limit} messages (use limit parameter to see more)")
67
+
68
+ # Get contextual tips from TIPS.md
69
+ tips = None
70
+ if rows:
71
+ # Focus on error/warning messages for debugging
72
+ error_rows = [r for r in rows if r.get("Level", "").upper() in ["ERROR", "WARN", "WARNING"]]
73
+ example_id = error_rows[0]["ID"] if error_rows else rows[0]["ID"]
74
+ tips = get_tips("console", context={"id": example_id})
75
+
76
+ # Use mode-specific truncation
77
+ truncate = _REPL_TRUNCATE if is_repl else _MCP_TRUNCATE
78
+
79
+ return table_response(
80
+ title="Console Messages",
81
+ headers=["ID", "Level", "Source", "Message", "Time"],
82
+ rows=rows,
83
+ summary=f"{len(rows)} messages",
84
+ warnings=warnings,
85
+ tips=tips,
86
+ truncate=truncate,
87
+ )
@@ -0,0 +1,310 @@
1
+ """HTTP fetch request interception and debugging commands."""
2
+
3
+ from webtap.app import app
4
+ from webtap.client import RPCError
5
+ from webtap.commands._builders import error_response, info_response
6
+ from webtap.commands._tips import get_mcp_description, get_tips
7
+
8
+ _fetch_desc = get_mcp_description("fetch")
9
+ _resume_desc = get_mcp_description("resume")
10
+ _fail_desc = get_mcp_description("fail")
11
+ _fulfill_desc = get_mcp_description("fulfill")
12
+
13
+
14
+ @app.command(
15
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _fetch_desc or ""}
16
+ )
17
+ def fetch(state, action: str, options: dict = None) -> dict: # pyright: ignore[reportArgumentType]
18
+ """Control fetch interception.
19
+
20
+ When enabled, requests pause for inspection.
21
+ Use requests() to see paused items, resume() or fail() to proceed.
22
+
23
+ Args:
24
+ action: Action to perform
25
+ - "enable" - Enable interception
26
+ - "disable" - Disable interception
27
+ - "status" - Get current status
28
+ options: Action-specific options
29
+ - For enable: {"response": true} - Also intercept responses
30
+
31
+ Examples:
32
+ fetch("status") # Check status
33
+ fetch("enable") # Enable request stage
34
+ fetch("enable", {"response": true}) # Both stages
35
+ fetch("disable") # Disable
36
+
37
+ Returns:
38
+ Fetch interception status
39
+ """
40
+ try:
41
+ if action == "disable":
42
+ state.client.call("fetch.disable")
43
+ return info_response(title="Fetch Disabled", fields={"Status": "Interception disabled"})
44
+
45
+ elif action == "enable":
46
+ response_stage = (options or {}).get("response", False)
47
+ result = state.client.call("fetch.enable", request=True, response=response_stage)
48
+
49
+ stages = "Request and Response stages" if result.get("response_stage") else "Request stage only"
50
+ return info_response(
51
+ title="Fetch Enabled",
52
+ fields={
53
+ "Stages": stages,
54
+ "Status": "Requests will pause",
55
+ },
56
+ )
57
+
58
+ elif action == "status":
59
+ status = state.client.call("status")
60
+ fetch_state = status.get("fetch", {})
61
+ fetch_enabled = fetch_state.get("enabled", False)
62
+ paused_count = fetch_state.get("paused_count", 0) if fetch_enabled else 0
63
+
64
+ return info_response(
65
+ title=f"Fetch Status: {'Enabled' if fetch_enabled else 'Disabled'}",
66
+ fields={
67
+ "Status": "Enabled" if fetch_enabled else "Disabled",
68
+ "Paused": f"{paused_count} requests paused" if fetch_enabled else "None",
69
+ },
70
+ )
71
+
72
+ else:
73
+ return error_response(f"Unknown action: {action}")
74
+
75
+ except RPCError as e:
76
+ return error_response(e.message)
77
+ except Exception as e:
78
+ return error_response(str(e))
79
+
80
+
81
+ @app.command(display="markdown", fastmcp={"type": "resource", "mime_type": "text/markdown"})
82
+ def requests(state, limit: int = 50) -> dict:
83
+ """Show paused requests. Equivalent to network(req_state="paused").
84
+
85
+ Args:
86
+ limit: Maximum items to show
87
+
88
+ Examples:
89
+ requests() # Show all paused
90
+ request(583) # View request details
91
+ resume(583) # Continue request
92
+
93
+ Returns:
94
+ Table of paused requests/responses in markdown
95
+ """
96
+ try:
97
+ # Get status to check if fetch is enabled
98
+ status = state.client.call("status")
99
+ if not status.get("fetch", {}).get("enabled", False):
100
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
101
+
102
+ # Delegate to network command with state filter
103
+ from webtap.commands.network import network
104
+
105
+ result = network(state, req_state="paused", limit=limit, show_all=True)
106
+
107
+ # Add fetch-specific tips if we have rows
108
+ if result.get("elements"):
109
+ # Find table element and extract first ID for tips
110
+ for element in result["elements"]:
111
+ if element.get("type") == "table":
112
+ rows = element.get("rows", [])
113
+ if rows and rows[0]:
114
+ example_id = rows[0].get("ID", 0)
115
+ tips = get_tips("requests", context={"id": example_id})
116
+ if tips:
117
+ # Add tips as alerts after table
118
+ tip_elements = [{"type": "alert", "content": tip, "level": "info"} for tip in tips]
119
+ # Insert after table
120
+ table_index = result["elements"].index(element)
121
+ result["elements"] = (
122
+ result["elements"][: table_index + 1]
123
+ + tip_elements
124
+ + result["elements"][table_index + 1 :]
125
+ )
126
+ break
127
+
128
+ return result
129
+
130
+ except RPCError as e:
131
+ return error_response(e.message)
132
+ except Exception as e:
133
+ return error_response(str(e))
134
+
135
+
136
+ @app.command(
137
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _resume_desc or ""}
138
+ )
139
+ def resume(state, request: int, wait: float = 0.5, modifications: dict = None) -> dict: # pyright: ignore[reportArgumentType]
140
+ """Resume a paused request.
141
+
142
+ For Request stage, can modify:
143
+ url, method, headers, postData
144
+
145
+ For Response stage, can modify:
146
+ responseCode, responseHeaders
147
+
148
+ Args:
149
+ request: Request ID from network() table
150
+ wait: Wait time for next event in seconds (default: 0.5)
151
+ modifications: Request/response modifications dict
152
+ - {"url": "..."} - Change URL
153
+ - {"method": "POST"} - Change method
154
+ - {"headers": [{"name": "X-Custom", "value": "test"}]} - Set headers
155
+ - {"responseCode": 404} - Change response code
156
+ - {"responseHeaders": [...]} - Modify response headers
157
+
158
+ Examples:
159
+ resume(583) # Simple resume
160
+ resume(583, wait=1.0) # Wait for redirect
161
+ resume(583, modifications={"url": "..."}) # Change URL
162
+ resume(583, modifications={"method": "POST"}) # Change method
163
+ resume(583, modifications={"headers": [{"name":"X-Custom","value":"test"}]})
164
+
165
+ Returns:
166
+ Continuation status with any follow-up events detected
167
+ """
168
+ try:
169
+ # Get status to check if fetch is enabled
170
+ status = state.client.call("status")
171
+ if not status.get("fetch", {}).get("enabled", False):
172
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
173
+
174
+ # Resume via RPC (now uses HAR ID)
175
+ result = state.client.call("fetch.resume", id=request, modifications=modifications, wait=wait)
176
+
177
+ # Build concise status line
178
+ har_id = result.get("id", request)
179
+ outcome = result.get("outcome", "unknown")
180
+ resumed_from = result.get("resumed_from", "unknown")
181
+
182
+ if outcome == "response":
183
+ status_code = result.get("status", "?")
184
+ summary = f"ID {har_id} → paused at Response ({status_code})"
185
+ elif outcome == "redirect":
186
+ redirect_id = result.get("redirect_id", "?")
187
+ summary = f"ID {har_id} → redirected to ID {redirect_id}"
188
+ elif outcome == "complete":
189
+ summary = f"ID {har_id} → complete"
190
+ else:
191
+ summary = f"ID {har_id} → resumed from {resumed_from}"
192
+
193
+ fields = {"Result": summary}
194
+ if result.get("remaining", 0) > 0:
195
+ fields["Remaining"] = f"{result['remaining']} paused"
196
+
197
+ return info_response(title="Resumed", fields=fields)
198
+
199
+ except RPCError as e:
200
+ return error_response(e.message)
201
+ except Exception as e:
202
+ return error_response(str(e))
203
+
204
+
205
+ @app.command(
206
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _fail_desc or ""}
207
+ )
208
+ def fail(state, request: int, reason: str = "BlockedByClient") -> dict:
209
+ """Fail a paused request.
210
+
211
+ Args:
212
+ request: Request ID from network() table
213
+ reason: CDP error reason (default: BlockedByClient)
214
+ Options: Failed, Aborted, TimedOut, AccessDenied,
215
+ ConnectionClosed, ConnectionReset, ConnectionRefused,
216
+ ConnectionAborted, ConnectionFailed, NameNotResolved,
217
+ InternetDisconnected, AddressUnreachable, BlockedByClient,
218
+ BlockedByResponse
219
+
220
+ Examples:
221
+ fail(583) # Fail specific request
222
+ fail(583, reason="AccessDenied") # Fail with specific reason
223
+
224
+ Returns:
225
+ Failure status
226
+ """
227
+ try:
228
+ # Get status to check if fetch is enabled
229
+ status = state.client.call("status")
230
+ if not status.get("fetch", {}).get("enabled", False):
231
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
232
+
233
+ # Fail via RPC (now uses HAR ID)
234
+ result = state.client.call("fetch.fail", id=request, reason=reason)
235
+
236
+ har_id = result.get("id", request)
237
+ summary = f"ID {har_id} → failed ({reason})"
238
+
239
+ fields = {"Result": summary}
240
+ if result.get("remaining", 0) > 0:
241
+ fields["Remaining"] = f"{result['remaining']} paused"
242
+
243
+ return info_response(title="Failed", fields=fields)
244
+
245
+ except RPCError as e:
246
+ return error_response(e.message)
247
+ except Exception as e:
248
+ return error_response(str(e))
249
+
250
+
251
+ @app.command(
252
+ display="markdown", fastmcp={"type": "tool", "mime_type": "text/markdown", "description": _fulfill_desc or ""}
253
+ )
254
+ def fulfill(
255
+ state,
256
+ request: int,
257
+ body: str = "",
258
+ status: int = 200,
259
+ headers: list = None, # pyright: ignore[reportArgumentType]
260
+ ) -> dict:
261
+ """Fulfill a paused request with a custom response.
262
+
263
+ Returns a mock response without hitting the server. Useful for:
264
+ - Mock API responses during development
265
+ - Test error handling with specific status codes
266
+ - Offline development without backend
267
+
268
+ Args:
269
+ request: Request ID from network() table
270
+ body: Response body content (default: empty)
271
+ status: HTTP status code (default: 200)
272
+ headers: Response headers as list of {"name": "...", "value": "..."} dicts
273
+
274
+ Examples:
275
+ fulfill(583) # Empty 200 response
276
+ fulfill(583, body='{"ok": true}') # JSON response
277
+ fulfill(583, body="Not Found", status=404) # Error response
278
+ fulfill(583, headers=[{"name": "Content-Type", "value": "application/json"}])
279
+
280
+ Returns:
281
+ Fulfillment status
282
+ """
283
+ try:
284
+ # Get status to check if fetch is enabled
285
+ fetch_status = state.client.call("status")
286
+ if not fetch_status.get("fetch", {}).get("enabled", False):
287
+ return error_response("Fetch interception is disabled. Use fetch('enable') first.")
288
+
289
+ # Fulfill via RPC (uses HAR ID)
290
+ result = state.client.call(
291
+ "fetch.fulfill",
292
+ id=request,
293
+ response_code=status,
294
+ response_headers=headers,
295
+ body=body,
296
+ )
297
+
298
+ har_id = result.get("id", request)
299
+ summary = f"ID {har_id} → fulfilled ({status})"
300
+
301
+ fields = {"Result": summary}
302
+ if result.get("remaining", 0) > 0:
303
+ fields["Remaining"] = f"{result['remaining']} paused"
304
+
305
+ return info_response(title="Fulfilled", fields=fields)
306
+
307
+ except RPCError as e:
308
+ return error_response(e.message)
309
+ except Exception as e:
310
+ return error_response(str(e))