Skip to content

Commit a37ecdd

Browse files
committed
feat: fix network interception — addInterception, stage routing, bulk ops
- Call Network.addInterception after setInterceptionEnabled (required by WebKit) - Call Network.enable before interception (required for events to dispatch) - Handle "already enabled" errors gracefully on reconnection - Add stage param to interceptContinue/interceptWithResponse - Use interceptRequestWithResponse for request-stage (not interceptWithResponse) - Use correct WebKit param names (status/statusText/mimeType/content) - Add intercept_continue_all and intercept_block_all bulk tools - Also handle Network.responseIntercepted events - Add version bump instructions to CLAUDE.md - Bump version to 0.3.3
1 parent cfc7281 commit a37ecdd

6 files changed

Lines changed: 243 additions & 32 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "iwdp-mcp",
3-
"version": "0.3.2",
3+
"version": "0.3.3",
44
"description": "iOS Safari debugging via ios-webkit-debug-proxy — MCP server with full WebKit Inspector Protocol support",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "iwdp-mcp",
3-
"version": "0.3.2",
3+
"version": "0.3.3",
44
"description": "iOS Safari debugging via ios-webkit-debug-proxy — MCP server with full WebKit Inspector Protocol support",
55
"mcpServers": {
66
"iwdp-mcp": {

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ fix/concurrent-write
122122
refactor/proxy-port-parsing
123123
```
124124

125+
## Version Bumps
126+
127+
When bumping the version, update all three files:
128+
129+
1. `cmd/iwdp-mcp/main.go``Version: "X.Y.Z"` in `mcp.Implementation`
130+
2. `.claude-plugin/plugin.json``"version": "X.Y.Z"`
131+
3. `.claude-plugin/marketplace.json``"version": "X.Y.Z"`
132+
133+
Then commit, push, and create a git tag:
134+
135+
```bash
136+
git add cmd/iwdp-mcp/main.go .claude-plugin/plugin.json .claude-plugin/marketplace.json
137+
git commit -m "chore: bump version to X.Y.Z"
138+
git push
139+
git tag vX.Y.Z
140+
git push origin vX.Y.Z
141+
```
142+
125143
## Key Dependencies
126144

127145
- `github.com/modelcontextprotocol/go-sdk` v1.4.0 — official MCP Go SDK

cmd/iwdp-mcp/main.go

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,26 @@ func getClient(ctx context.Context) (*webkit.Client, error) {
3939
return nil, fmt.Errorf("no page selected — use select_page first")
4040
}
4141

42+
// lookupInterceptStage finds the stage for an intercepted request from the collector.
43+
func lookupInterceptStage(requestID string) string {
44+
sess.mu.Lock()
45+
ic := sess.interceptionCollector
46+
sess.mu.Unlock()
47+
if ic == nil {
48+
return "request"
49+
}
50+
for _, req := range ic.GetPending() {
51+
if req.RequestID == requestID {
52+
return req.Stage
53+
}
54+
}
55+
return "request"
56+
}
57+
4258
func main() {
4359
server := mcp.NewServer(&mcp.Implementation{
4460
Name: "iwdp-mcp",
45-
Version: "0.3.2",
61+
Version: "0.3.3",
4662
}, nil)
4763

4864
registerTools(server)
@@ -201,18 +217,22 @@ type SetExtraHeadersInput struct {
201217
}
202218

203219
type SetRequestInterceptionInput struct {
204-
Enabled bool `json:"enabled" jsonschema:"enable or disable request interception"`
220+
Enabled bool `json:"enabled" jsonschema:"enable or disable request interception"`
221+
URLPattern string `json:"url_pattern,omitempty" jsonschema:"URL pattern to intercept (empty = all requests)"`
222+
Stage string `json:"stage,omitempty" jsonschema:"interception stage: request or response (default: request)"`
223+
IsRegex bool `json:"is_regex,omitempty" jsonschema:"treat url_pattern as regex"`
205224
}
206225

207226
type InterceptContinueInput struct {
208227
RequestID string `json:"request_id" jsonschema:"intercepted request ID"`
209228
}
210229

211230
type InterceptWithResponseInput struct {
212-
RequestID string `json:"request_id" jsonschema:"intercepted request ID"`
213-
StatusCode int `json:"status_code" jsonschema:"HTTP status code"`
214-
Headers map[string]string `json:"headers,omitempty" jsonschema:"response headers"`
215-
Body string `json:"body,omitempty" jsonschema:"response body"`
231+
RequestID string `json:"request_id" jsonschema:"intercepted request ID"`
232+
StatusCode int `json:"status_code" jsonschema:"HTTP status code"`
233+
Headers map[string]string `json:"headers,omitempty" jsonschema:"response headers"`
234+
Content string `json:"content,omitempty" jsonschema:"response body content"`
235+
Base64Encoded bool `json:"base64_encoded,omitempty" jsonschema:"whether content is base64-encoded"`
216236
}
217237

218238
type SetEmulatedConditionsInput struct {
@@ -988,7 +1008,7 @@ func registerTools(server *mcp.Server) {
9881008
}
9891009
ic := sess.interceptionCollector
9901010
sess.mu.Unlock()
991-
return nil, ok(), ic.Start(ctx, c)
1011+
return nil, ok(), ic.Start(ctx, c, input.URLPattern, input.Stage, input.IsRegex)
9921012
}
9931013
sess.mu.Lock()
9941014
ic := sess.interceptionCollector
@@ -1018,7 +1038,8 @@ func registerTools(server *mcp.Server) {
10181038
if err != nil {
10191039
return nil, OKOutput{}, err
10201040
}
1021-
err = tools.InterceptContinue(ctx, c, input.RequestID)
1041+
stage := lookupInterceptStage(input.RequestID)
1042+
err = tools.InterceptContinue(ctx, c, input.RequestID, stage)
10221043
if err == nil {
10231044
sess.mu.Lock()
10241045
if sess.interceptionCollector != nil {
@@ -1036,7 +1057,8 @@ func registerTools(server *mcp.Server) {
10361057
if err != nil {
10371058
return nil, OKOutput{}, err
10381059
}
1039-
err = tools.InterceptWithResponse(ctx, c, input.RequestID, input.StatusCode, input.Headers, input.Body)
1060+
stage := lookupInterceptStage(input.RequestID)
1061+
err = tools.InterceptWithResponse(ctx, c, input.RequestID, stage, input.StatusCode, input.Headers, input.Content, input.Base64Encoded)
10401062
if err == nil {
10411063
sess.mu.Lock()
10421064
if sess.interceptionCollector != nil {
@@ -1047,6 +1069,54 @@ func registerTools(server *mcp.Server) {
10471069
return nil, ok(), err
10481070
})
10491071

1072+
mcp.AddTool(server, &mcp.Tool{
1073+
Name: "intercept_continue_all", Description: "Continue all pending intercepted requests without modification",
1074+
}, func(ctx context.Context, req *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, any, error) {
1075+
c, err := getClient(ctx)
1076+
if err != nil {
1077+
return nil, OKOutput{}, err
1078+
}
1079+
sess.mu.Lock()
1080+
ic := sess.interceptionCollector
1081+
sess.mu.Unlock()
1082+
if ic == nil {
1083+
return nil, RawOutput{Result: map[string]int{"continued": 0}}, nil
1084+
}
1085+
pending := ic.GetPending()
1086+
continued := 0
1087+
for _, r := range pending {
1088+
if err := tools.InterceptContinue(ctx, c, r.RequestID, r.Stage); err == nil {
1089+
ic.RemovePending(r.RequestID)
1090+
continued++
1091+
}
1092+
}
1093+
return nil, RawOutput{Result: map[string]int{"continued": continued}}, nil
1094+
})
1095+
1096+
mcp.AddTool(server, &mcp.Tool{
1097+
Name: "intercept_block_all", Description: "Block all pending intercepted requests with 403 Forbidden",
1098+
}, func(ctx context.Context, req *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, any, error) {
1099+
c, err := getClient(ctx)
1100+
if err != nil {
1101+
return nil, OKOutput{}, err
1102+
}
1103+
sess.mu.Lock()
1104+
ic := sess.interceptionCollector
1105+
sess.mu.Unlock()
1106+
if ic == nil {
1107+
return nil, RawOutput{Result: map[string]int{"blocked": 0}}, nil
1108+
}
1109+
pending := ic.GetPending()
1110+
blocked := 0
1111+
for _, r := range pending {
1112+
if err := tools.InterceptWithResponse(ctx, c, r.RequestID, r.Stage, 403, map[string]string{"Content-Type": "text/plain"}, "Blocked", false); err == nil {
1113+
ic.RemovePending(r.RequestID)
1114+
blocked++
1115+
}
1116+
}
1117+
return nil, RawOutput{Result: map[string]int{"blocked": blocked}}, nil
1118+
})
1119+
10501120
mcp.AddTool(server, &mcp.Tool{
10511121
Name: "set_emulated_conditions", Description: "Throttle network speed",
10521122
}, func(ctx context.Context, req *mcp.CallToolRequest, input SetEmulatedConditionsInput) (*mcp.CallToolResult, any, error) {

internal/tools/network.go

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,43 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"net/http"
8+
"strings"
79
"sync"
810

911
"github.com/nnemirovsky/iwdp-mcp/internal/webkit"
1012
)
1113

14+
// httpStatusText returns the standard status text for an HTTP status code.
15+
func httpStatusText(code int) string {
16+
text := http.StatusText(code)
17+
if text == "" {
18+
return "Unknown"
19+
}
20+
return text
21+
}
22+
23+
// mimeTypeFromHeaders extracts the MIME type from a Content-Type header, defaulting to text/plain.
24+
func mimeTypeFromHeaders(headers map[string]string) string {
25+
for k, v := range headers {
26+
if strings.EqualFold(k, "content-type") {
27+
// Strip charset etc: "text/html; charset=utf-8" → "text/html"
28+
if idx := strings.IndexByte(v, ';'); idx >= 0 {
29+
return strings.TrimSpace(v[:idx])
30+
}
31+
return v
32+
}
33+
}
34+
return "text/plain"
35+
}
36+
37+
// isAlreadyEnabledErr checks if the error is "Interception already enabled/disabled",
38+
// which happens when a previous session left state on WebKit's side.
39+
func isAlreadyEnabledErr(err error) bool {
40+
return err != nil && (strings.Contains(err.Error(), "already enabled") ||
41+
strings.Contains(err.Error(), "already disabled"))
42+
}
43+
1244
// NetworkMonitor collects network requests and responses.
1345
type NetworkMonitor struct {
1446
mu sync.Mutex
@@ -164,6 +196,7 @@ func SetExtraHeaders(ctx context.Context, client *webkit.Client, headers map[str
164196
// InterceptedRequest holds an intercepted request waiting for a continue/response decision.
165197
type InterceptedRequest struct {
166198
RequestID string `json:"request_id"`
199+
Stage string `json:"stage"`
167200
Request webkit.NetworkRequest `json:"request"`
168201
}
169202

@@ -181,8 +214,11 @@ func NewInterceptionCollector() *InterceptionCollector {
181214
}
182215
}
183216

184-
// Start enables request interception and registers the event handler.
185-
func (ic *InterceptionCollector) Start(ctx context.Context, client *webkit.Client) error {
217+
// Start enables request interception, adds an interception rule, and registers the event handler.
218+
// urlPattern is a URL pattern to intercept (empty string = all requests).
219+
// stage is "request" or "response" (empty defaults to "request").
220+
// isRegex controls whether urlPattern is treated as a regex.
221+
func (ic *InterceptionCollector) Start(ctx context.Context, client *webkit.Client, urlPattern, stage string, isRegex bool) error {
186222
ic.mu.Lock()
187223
if ic.started {
188224
ic.mu.Unlock()
@@ -191,14 +227,42 @@ func (ic *InterceptionCollector) Start(ctx context.Context, client *webkit.Clien
191227
ic.started = true
192228
ic.mu.Unlock()
193229

230+
// Network domain must be enabled for interception events to be dispatched.
231+
_, _ = client.Send(ctx, "Network.enable", nil)
232+
194233
_, err := client.Send(ctx, "Network.setInterceptionEnabled", map[string]interface{}{
195234
"enabled": true,
196235
})
197236
if err != nil {
237+
// "Interception already enabled" is non-fatal — a previous session may have
238+
// left it on. We still need to add rules and register the event handler.
239+
if !isAlreadyEnabledErr(err) {
240+
ic.mu.Lock()
241+
ic.started = false
242+
ic.mu.Unlock()
243+
return err
244+
}
245+
}
246+
247+
if stage == "" {
248+
stage = "request"
249+
}
250+
251+
// Register an interception rule — without this, no requestIntercepted events fire.
252+
_, err = client.Send(ctx, "Network.addInterception", map[string]interface{}{
253+
"url": urlPattern,
254+
"stage": stage,
255+
"isRegex": isRegex,
256+
})
257+
if err != nil {
258+
// Roll back — disable interception if we can't add a rule.
259+
_, _ = client.Send(ctx, "Network.setInterceptionEnabled", map[string]interface{}{
260+
"enabled": false,
261+
})
198262
ic.mu.Lock()
199263
ic.started = false
200264
ic.mu.Unlock()
201-
return err
265+
return fmt.Errorf("adding interception rule: %w", err)
202266
}
203267

204268
client.OnEvent("Network.requestIntercepted", func(method string, params json.RawMessage) {
@@ -212,6 +276,24 @@ func (ic *InterceptionCollector) Start(ctx context.Context, client *webkit.Clien
212276
ic.mu.Lock()
213277
ic.pending[evt.RequestID] = &InterceptedRequest{
214278
RequestID: evt.RequestID,
279+
Stage: "request",
280+
Request: evt.Request,
281+
}
282+
ic.mu.Unlock()
283+
})
284+
285+
client.OnEvent("Network.responseIntercepted", func(method string, params json.RawMessage) {
286+
var evt struct {
287+
RequestID string `json:"requestId"`
288+
Request webkit.NetworkRequest `json:"request"`
289+
}
290+
if err := json.Unmarshal(params, &evt); err != nil {
291+
return
292+
}
293+
ic.mu.Lock()
294+
ic.pending[evt.RequestID] = &InterceptedRequest{
295+
RequestID: evt.RequestID,
296+
Stage: "response",
215297
Request: evt.Request,
216298
}
217299
ic.mu.Unlock()
@@ -220,12 +302,17 @@ func (ic *InterceptionCollector) Start(ctx context.Context, client *webkit.Clien
220302
return nil
221303
}
222304

223-
// Stop disables request interception.
305+
// Stop disables request interception and removes all interception rules.
224306
func (ic *InterceptionCollector) Stop(ctx context.Context, client *webkit.Client) error {
225307
ic.mu.Lock()
226308
ic.started = false
227309
ic.pending = make(map[string]*InterceptedRequest)
228310
ic.mu.Unlock()
311+
// removeInterception is best-effort — the disable call below will clear everything anyway.
312+
_, _ = client.Send(ctx, "Network.removeInterception", map[string]interface{}{
313+
"url": "",
314+
"stage": "request",
315+
})
229316
_, err := client.Send(ctx, "Network.setInterceptionEnabled", map[string]interface{}{
230317
"enabled": false,
231318
})
@@ -260,20 +347,50 @@ func SetRequestInterception(ctx context.Context, client *webkit.Client, enabled
260347
}
261348

262349
// InterceptContinue continues an intercepted request without modification.
263-
func InterceptContinue(ctx context.Context, client *webkit.Client, requestID string) error {
350+
func InterceptContinue(ctx context.Context, client *webkit.Client, requestID, stage string) error {
351+
if stage == "" {
352+
stage = "request"
353+
}
264354
_, err := client.Send(ctx, "Network.interceptContinue", map[string]string{
265355
"requestId": requestID,
356+
"stage": stage,
266357
})
267358
return err
268359
}
269360

270361
// InterceptWithResponse provides a custom response for an intercepted request.
271-
func InterceptWithResponse(ctx context.Context, client *webkit.Client, requestID string, statusCode int, headers map[string]string, body string) error {
362+
// For request-stage interceptions, uses Network.interceptRequestWithResponse (synthetic response).
363+
// For response-stage interceptions, uses Network.interceptWithResponse (modify received response).
364+
func InterceptWithResponse(ctx context.Context, client *webkit.Client, requestID string, stage string, statusCode int, headers map[string]string, content string, base64Encoded bool) error {
365+
if stage == "" {
366+
stage = "request"
367+
}
368+
if headers == nil {
369+
headers = map[string]string{}
370+
}
371+
372+
if stage == "request" {
373+
// interceptRequestWithResponse: synthetic response at request stage (skip network).
374+
_, err := client.Send(ctx, "Network.interceptRequestWithResponse", map[string]interface{}{
375+
"requestId": requestID,
376+
"status": statusCode,
377+
"statusText": httpStatusText(statusCode),
378+
"mimeType": mimeTypeFromHeaders(headers),
379+
"content": content,
380+
"base64Encoded": base64Encoded,
381+
"headers": headers,
382+
})
383+
return err
384+
}
385+
386+
// interceptWithResponse: modify response at response stage.
272387
_, err := client.Send(ctx, "Network.interceptWithResponse", map[string]interface{}{
273-
"requestId": requestID,
274-
"statusCode": statusCode,
275-
"headers": headers,
276-
"body": body,
388+
"requestId": requestID,
389+
"stage": stage,
390+
"statusCode": statusCode,
391+
"headers": headers,
392+
"content": content,
393+
"base64Encoded": base64Encoded,
277394
})
278395
return err
279396
}

0 commit comments

Comments
 (0)