bingo-light 2.0.0

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.
package/mcp-server.py ADDED
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bingo-light MCP Server — Zero-dependency MCP tool server.
4
+
5
+ Exposes bingo-light CLI commands as MCP tools so any MCP-compatible LLM client
6
+ (Claude Code, Claude Desktop, VS Code Copilot, Cursor, etc.) can call them directly.
7
+
8
+ Protocol: JSON-RPC 2.0 over stdio (MCP specification).
9
+ Dependencies: Python 3.8+ standard library only.
10
+
11
+ Usage:
12
+ # Run directly:
13
+ python3 mcp-server.py
14
+
15
+ # In Claude Code settings.json:
16
+ { "mcpServers": { "bingo-light": { "command": "python3", "args": ["/path/to/mcp-server.py"] } } }
17
+
18
+ # In Claude Desktop config:
19
+ { "mcpServers": { "bingo-light": { "command": "python3", "args": ["/path/to/mcp-server.py"] } } }
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+
28
+ # ─── Direct import of bingo_core ─────────────────────────────────────────────
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ from bingo_core import Repo, BingoError # noqa: E402
32
+
33
+ # ─── Tool Definitions ─────────────────────────────────────────────────────────
34
+
35
+ TOOLS = [
36
+ {
37
+ "name": "bingo_status",
38
+ "description": (
39
+ "Check the health of your fork. Returns recommended_action telling you exactly "
40
+ "what to do next: 'up_to_date', 'sync_safe', 'sync_risky', or 'resolve_conflict'. "
41
+ "ALWAYS call this FIRST. Read the recommended_action field — don't guess."
42
+ ),
43
+ "inputSchema": {
44
+ "type": "object",
45
+ "properties": {
46
+ "cwd": {
47
+ "type": "string",
48
+ "description": "Path to the git repository (required)"
49
+ }
50
+ },
51
+ "required": ["cwd"]
52
+ }
53
+ },
54
+ {
55
+ "name": "bingo_init",
56
+ "description": (
57
+ "Initialize bingo-light in a git repository. Sets up upstream tracking, "
58
+ "creates patch branch, enables rerere. Run once per forked project."
59
+ ),
60
+ "inputSchema": {
61
+ "type": "object",
62
+ "properties": {
63
+ "cwd": {
64
+ "type": "string",
65
+ "description": "Path to the git repository"
66
+ },
67
+ "upstream_url": {
68
+ "type": "string",
69
+ "description": "URL of the original upstream repository"
70
+ },
71
+ "branch": {
72
+ "type": "string",
73
+ "description": "Upstream branch to track (default: auto-detect)"
74
+ }
75
+ },
76
+ "required": ["cwd", "upstream_url"]
77
+ }
78
+ },
79
+ {
80
+ "name": "bingo_patch_new",
81
+ "description": (
82
+ "Create a new patch from current changes. Each patch = one atomic customization "
83
+ "on top of upstream. Stage changes first (git add) or let it auto-stage everything."
84
+ ),
85
+ "inputSchema": {
86
+ "type": "object",
87
+ "properties": {
88
+ "cwd": {
89
+ "type": "string",
90
+ "description": "Path to the git repository"
91
+ },
92
+ "name": {
93
+ "type": "string",
94
+ "description": "Patch name (alphanumeric, hyphens, underscores)"
95
+ },
96
+ "description": {
97
+ "type": "string",
98
+ "description": "Brief one-line description of the patch"
99
+ }
100
+ },
101
+ "required": ["cwd", "name"]
102
+ }
103
+ },
104
+ {
105
+ "name": "bingo_patch_list",
106
+ "description": "List all patches in the stack with stats. Use verbose=true for per-file details.",
107
+ "inputSchema": {
108
+ "type": "object",
109
+ "properties": {
110
+ "cwd": {
111
+ "type": "string",
112
+ "description": "Path to the git repository"
113
+ },
114
+ "verbose": {
115
+ "type": "boolean",
116
+ "description": "Show per-file change details (default: false)"
117
+ }
118
+ },
119
+ "required": ["cwd"]
120
+ }
121
+ },
122
+ {
123
+ "name": "bingo_patch_show",
124
+ "description": "Show full diff and stats for a specific patch.",
125
+ "inputSchema": {
126
+ "type": "object",
127
+ "properties": {
128
+ "cwd": {
129
+ "type": "string",
130
+ "description": "Path to the git repository"
131
+ },
132
+ "target": {
133
+ "type": "string",
134
+ "description": "Patch name or 1-based index"
135
+ }
136
+ },
137
+ "required": ["cwd", "target"]
138
+ }
139
+ },
140
+ {
141
+ "name": "bingo_patch_drop",
142
+ "description": "Remove a patch from the stack.",
143
+ "inputSchema": {
144
+ "type": "object",
145
+ "properties": {
146
+ "cwd": {
147
+ "type": "string",
148
+ "description": "Path to the git repository"
149
+ },
150
+ "target": {
151
+ "type": "string",
152
+ "description": "Patch name or 1-based index"
153
+ }
154
+ },
155
+ "required": ["cwd", "target"]
156
+ }
157
+ },
158
+ {
159
+ "name": "bingo_patch_export",
160
+ "description": "Export all patches as numbered .patch files (git format-patch) plus quilt-compatible series file.",
161
+ "inputSchema": {
162
+ "type": "object",
163
+ "properties": {
164
+ "cwd": {
165
+ "type": "string",
166
+ "description": "Path to the git repository"
167
+ },
168
+ "output_dir": {
169
+ "type": "string",
170
+ "description": "Output directory (default: .bl-patches)"
171
+ }
172
+ },
173
+ "required": ["cwd"]
174
+ }
175
+ },
176
+ {
177
+ "name": "bingo_patch_import",
178
+ "description": "Import .patch file(s) into the stack.",
179
+ "inputSchema": {
180
+ "type": "object",
181
+ "properties": {
182
+ "cwd": {
183
+ "type": "string",
184
+ "description": "Path to the git repository"
185
+ },
186
+ "path": {
187
+ "type": "string",
188
+ "description": "Path to .patch file or directory of patches"
189
+ }
190
+ },
191
+ "required": ["cwd", "path"]
192
+ }
193
+ },
194
+ {
195
+ "name": "bingo_sync",
196
+ "description": (
197
+ "Low-level sync: fetch upstream and rebase patches. Prefer bingo_smart_sync instead — "
198
+ "it handles conflicts automatically. Only use bingo_sync when you need dry_run preview "
199
+ "or fine-grained control over the rebase process."
200
+ ),
201
+ "inputSchema": {
202
+ "type": "object",
203
+ "properties": {
204
+ "cwd": {
205
+ "type": "string",
206
+ "description": "Path to the git repository"
207
+ },
208
+ "dry_run": {
209
+ "type": "boolean",
210
+ "description": "Preview only, don't modify anything (default: false)"
211
+ }
212
+ },
213
+ "required": ["cwd"]
214
+ }
215
+ },
216
+ {
217
+ "name": "bingo_undo",
218
+ "description": "Undo the last sync operation by restoring patches branch to previous state.",
219
+ "inputSchema": {
220
+ "type": "object",
221
+ "properties": {
222
+ "cwd": {
223
+ "type": "string",
224
+ "description": "Path to the git repository"
225
+ }
226
+ },
227
+ "required": ["cwd"]
228
+ }
229
+ },
230
+ {
231
+ "name": "bingo_doctor",
232
+ "description": (
233
+ "Diagnose setup issues: checks git version, rerere, upstream remote, branch structure, "
234
+ "and tests whether patches apply cleanly on latest upstream."
235
+ ),
236
+ "inputSchema": {
237
+ "type": "object",
238
+ "properties": {
239
+ "cwd": {
240
+ "type": "string",
241
+ "description": "Path to the git repository"
242
+ }
243
+ },
244
+ "required": ["cwd"]
245
+ }
246
+ },
247
+ {
248
+ "name": "bingo_diff",
249
+ "description": "Show combined diff of all patches vs upstream (total fork divergence).",
250
+ "inputSchema": {
251
+ "type": "object",
252
+ "properties": {
253
+ "cwd": {
254
+ "type": "string",
255
+ "description": "Path to the git repository"
256
+ }
257
+ },
258
+ "required": ["cwd"]
259
+ }
260
+ },
261
+ {
262
+ "name": "bingo_auto_sync",
263
+ "description": (
264
+ "Generate GitHub Actions workflow for automated daily upstream sync. "
265
+ "Creates .github/workflows/bingo-light-sync.yml."
266
+ ),
267
+ "inputSchema": {
268
+ "type": "object",
269
+ "properties": {
270
+ "cwd": {
271
+ "type": "string",
272
+ "description": "Path to the git repository"
273
+ },
274
+ "schedule": {
275
+ "type": "string",
276
+ "enum": ["daily", "6h", "weekly"],
277
+ "description": "Sync frequency (default: daily)"
278
+ }
279
+ },
280
+ "required": ["cwd"]
281
+ }
282
+ },
283
+ {
284
+ "name": "bingo_conflict_analyze",
285
+ "description": (
286
+ "Analyze current rebase conflicts. Returns structured info about each conflicted file: "
287
+ "the 'ours' version (upstream), 'theirs' version (your patch), conflict count, and resolution hints. "
288
+ "Call this when bingo_sync reports a conflict to understand what needs fixing."
289
+ ),
290
+ "inputSchema": {
291
+ "type": "object",
292
+ "properties": {
293
+ "cwd": {
294
+ "type": "string",
295
+ "description": "Path to the git repository"
296
+ }
297
+ },
298
+ "required": ["cwd"]
299
+ }
300
+ },
301
+ {
302
+ "name": "bingo_conflict_resolve",
303
+ "description": (
304
+ "Resolve a conflict during rebase by writing the resolved content to a file, "
305
+ "staging it, and continuing the rebase. Use after bingo_conflict_analyze."
306
+ ),
307
+ "inputSchema": {
308
+ "type": "object",
309
+ "properties": {
310
+ "cwd": {
311
+ "type": "string",
312
+ "description": "Path to the git repository"
313
+ },
314
+ "file": {
315
+ "type": "string",
316
+ "description": "Path to the conflicted file (relative to repo root)"
317
+ },
318
+ "content": {
319
+ "type": "string",
320
+ "description": "The fully resolved file content (no conflict markers)"
321
+ }
322
+ },
323
+ "required": ["cwd", "file", "content"]
324
+ }
325
+ },
326
+ {
327
+ "name": "bingo_config",
328
+ "description": "Get, set, or list bingo-light configuration values.",
329
+ "inputSchema": {
330
+ "type": "object",
331
+ "properties": {
332
+ "cwd": {"type": "string", "description": "Path to the git repository"},
333
+ "action": {"type": "string", "enum": ["get", "set", "list"], "description": "Config action"},
334
+ "key": {"type": "string", "description": "Config key (for get/set)"},
335
+ "value": {"type": "string", "description": "Config value (for set)"}
336
+ },
337
+ "required": ["cwd", "action"]
338
+ }
339
+ },
340
+ {
341
+ "name": "bingo_history",
342
+ "description": "Show sync history: timestamps, upstream commits integrated, patch hash mappings.",
343
+ "inputSchema": {
344
+ "type": "object",
345
+ "properties": {"cwd": {"type": "string", "description": "Path to the git repository"}},
346
+ "required": ["cwd"]
347
+ }
348
+ },
349
+ {
350
+ "name": "bingo_test",
351
+ "description": "Run the configured test command. Set test command first: config set test.command 'make test'.",
352
+ "inputSchema": {
353
+ "type": "object",
354
+ "properties": {"cwd": {"type": "string", "description": "Path to the git repository"}},
355
+ "required": ["cwd"]
356
+ }
357
+ },
358
+ {
359
+ "name": "bingo_patch_meta",
360
+ "description": "Get or set patch metadata (reason, tags, expires, upstream_pr, status).",
361
+ "inputSchema": {
362
+ "type": "object",
363
+ "properties": {
364
+ "cwd": {"type": "string", "description": "Path to the git repository"},
365
+ "name": {"type": "string", "description": "Patch name"},
366
+ "set_field": {"type": "string", "enum": ["reason", "tag", "expires", "upstream_pr", "status"], "description": "Field to set (omit to get)"},
367
+ "value": {"type": "string", "description": "Value to set"}
368
+ },
369
+ "required": ["cwd", "name"]
370
+ }
371
+ },
372
+ {
373
+ "name": "bingo_patch_squash",
374
+ "description": "Squash two adjacent patches into one.",
375
+ "inputSchema": {
376
+ "type": "object",
377
+ "properties": {
378
+ "cwd": {"type": "string", "description": "Path to the git repository"},
379
+ "index1": {"type": "integer", "description": "First patch index (1-based)"},
380
+ "index2": {"type": "integer", "description": "Second patch index (1-based)"}
381
+ },
382
+ "required": ["cwd", "index1", "index2"]
383
+ }
384
+ },
385
+ {
386
+ "name": "bingo_patch_reorder",
387
+ "description": "Reorder patches non-interactively. Provide new order as comma-separated indices.",
388
+ "inputSchema": {
389
+ "type": "object",
390
+ "properties": {
391
+ "cwd": {"type": "string", "description": "Path to the git repository"},
392
+ "order": {"type": "string", "description": "New order as comma-separated indices, e.g. '3,1,2'"}
393
+ },
394
+ "required": ["cwd", "order"]
395
+ }
396
+ },
397
+ {
398
+ "name": "bingo_workspace_status",
399
+ "description": "Show status of all repos in the workspace (multi-repo overview).",
400
+ "inputSchema": {
401
+ "type": "object",
402
+ "properties": {
403
+ "cwd": {"type": "string", "description": "Any directory (workspace config is global)"}
404
+ },
405
+ "required": ["cwd"]
406
+ }
407
+ },
408
+ {
409
+ "name": "bingo_patch_edit",
410
+ "description": "Amend an existing patch by folding staged changes into it. Stage changes with git add first, then call this.",
411
+ "inputSchema": {
412
+ "type": "object",
413
+ "properties": {
414
+ "cwd": {"type": "string", "description": "Path to the git repository"},
415
+ "target": {"type": "string", "description": "Patch name or index to edit"}
416
+ },
417
+ "required": ["cwd", "target"]
418
+ }
419
+ },
420
+ {
421
+ "name": "bingo_workspace_init",
422
+ "description": "Initialize a multi-repo workspace.",
423
+ "inputSchema": {"type": "object", "properties": {"cwd": {"type": "string"}}, "required": ["cwd"]}
424
+ },
425
+ {
426
+ "name": "bingo_workspace_add",
427
+ "description": "Add a repository to the workspace.",
428
+ "inputSchema": {"type": "object", "properties": {"cwd": {"type": "string"}, "path": {"type": "string"}, "alias": {"type": "string"}}, "required": ["cwd", "path"]}
429
+ },
430
+ {
431
+ "name": "bingo_workspace_sync",
432
+ "description": "Sync all repositories in the workspace.",
433
+ "inputSchema": {"type": "object", "properties": {"cwd": {"type": "string"}}, "required": ["cwd"]}
434
+ },
435
+ {
436
+ "name": "bingo_workspace_list",
437
+ "description": "List all repositories in the workspace.",
438
+ "inputSchema": {"type": "object", "properties": {"cwd": {"type": "string"}}, "required": ["cwd"]}
439
+ },
440
+ {
441
+ "name": "bingo_smart_sync",
442
+ "description": (
443
+ "ONE-SHOT SYNC: Fetches upstream, rebases all patches, and auto-resolves conflicts "
444
+ "via rerere — all in a single call. Returns synced result or remaining conflicts with "
445
+ "ours/theirs/merge_hint for each. USE THIS instead of bingo_sync when you want the "
446
+ "simplest possible sync flow. Only calls you back if rerere can't auto-resolve."
447
+ ),
448
+ "inputSchema": {
449
+ "type": "object",
450
+ "properties": {
451
+ "cwd": {"type": "string", "description": "Path to the git repository"}
452
+ },
453
+ "required": ["cwd"]
454
+ }
455
+ },
456
+ {
457
+ "name": "bingo_session",
458
+ "description": (
459
+ "Read or update AI session notes (.bingo/session.md). Call with update=true "
460
+ "at the START of a conversation to snapshot fork state. Read without update "
461
+ "to get cached context without running expensive git commands."
462
+ ),
463
+ "inputSchema": {
464
+ "type": "object",
465
+ "properties": {
466
+ "cwd": {"type": "string", "description": "Path to the git repository"},
467
+ "update": {"type": "boolean", "description": "If true, regenerate notes from current state"}
468
+ },
469
+ "required": ["cwd"]
470
+ }
471
+ },
472
+ ]
473
+
474
+ # ─── Command Mapping ──────────────────────────────────────────────────────────
475
+
476
+
477
+ def _result(data: dict) -> dict:
478
+ """Convert a Repo method return dict to MCP tool result format."""
479
+ is_error = not data.get("ok", False)
480
+ return {
481
+ "content": [{"type": "text", "text": json.dumps(data)}],
482
+ "isError": is_error,
483
+ }
484
+
485
+
486
+ def handle_tool_call(name: str, arguments: dict) -> dict:
487
+ """Map MCP tool calls to bingo_core.Repo methods directly."""
488
+ cwd = arguments.get("cwd", ".")
489
+
490
+ # Type validation — MCP clients can send any JSON type
491
+ if not isinstance(cwd, str):
492
+ return {"content": [{"type": "text", "text": f"Invalid cwd: expected string, got {type(cwd).__name__}"}], "isError": True}
493
+
494
+ # Validate cwd is a real directory (prevent arbitrary filesystem access)
495
+ if not os.path.isdir(cwd):
496
+ return {"content": [{"type": "text", "text": f"Invalid cwd: directory does not exist: {cwd}"}], "isError": True}
497
+
498
+ try:
499
+ repo = Repo(cwd)
500
+
501
+ if name == "bingo_status":
502
+ return _result(repo.status())
503
+
504
+ elif name == "bingo_init":
505
+ return _result(repo.init(
506
+ arguments["upstream_url"],
507
+ arguments.get("branch", ""),
508
+ ))
509
+
510
+ elif name == "bingo_sync":
511
+ return _result(repo.sync(
512
+ dry_run=bool(arguments.get("dry_run")),
513
+ force=True, # MCP calls are non-interactive
514
+ ))
515
+
516
+ elif name == "bingo_smart_sync":
517
+ return _result(repo.smart_sync())
518
+
519
+ elif name == "bingo_undo":
520
+ return _result(repo.undo())
521
+
522
+ elif name == "bingo_doctor":
523
+ return _result(repo.doctor())
524
+
525
+ elif name == "bingo_diff":
526
+ return _result(repo.diff())
527
+
528
+ elif name == "bingo_history":
529
+ return _result(repo.history())
530
+
531
+ elif name == "bingo_conflict_analyze":
532
+ return _result(repo.conflict_analyze())
533
+
534
+ elif name == "bingo_conflict_resolve":
535
+ return _result(repo.conflict_resolve(
536
+ arguments.get("file", ""),
537
+ arguments.get("content", ""),
538
+ ))
539
+
540
+ elif name == "bingo_log":
541
+ return _result(repo.history())
542
+
543
+ elif name == "bingo_config":
544
+ action = arguments.get("action", "list")
545
+ if action == "get":
546
+ return _result(repo.config_get(arguments.get("key", "")))
547
+ elif action == "set":
548
+ return _result(repo.config_set(
549
+ arguments.get("key", ""),
550
+ arguments.get("value", ""),
551
+ ))
552
+ else:
553
+ return _result(repo.config_list())
554
+
555
+ elif name == "bingo_test":
556
+ return _result(repo.test())
557
+
558
+ elif name == "bingo_auto_sync":
559
+ return _result(repo.auto_sync(
560
+ schedule=arguments.get("schedule", "daily"),
561
+ ))
562
+
563
+ elif name == "bingo_session":
564
+ return _result(repo.session(
565
+ update=bool(arguments.get("update")),
566
+ ))
567
+
568
+ elif name == "bingo_patch_new":
569
+ return _result(repo.patch_new(
570
+ arguments["name"],
571
+ arguments.get("description", "no description"),
572
+ ))
573
+
574
+ elif name == "bingo_patch_list":
575
+ return _result(repo.patch_list(
576
+ verbose=bool(arguments.get("verbose")),
577
+ ))
578
+
579
+ elif name == "bingo_patch_show":
580
+ return _result(repo.patch_show(arguments["target"]))
581
+
582
+ elif name == "bingo_patch_drop":
583
+ return _result(repo.patch_drop(arguments["target"]))
584
+
585
+ elif name == "bingo_patch_edit":
586
+ return _result(repo.patch_edit(arguments["target"]))
587
+
588
+ elif name == "bingo_patch_export":
589
+ return _result(repo.patch_export(
590
+ arguments.get("output_dir", ".bl-patches"),
591
+ ))
592
+
593
+ elif name == "bingo_patch_import":
594
+ return _result(repo.patch_import(arguments["path"]))
595
+
596
+ elif name == "bingo_patch_meta":
597
+ return _result(repo.patch_meta(
598
+ arguments["name"],
599
+ arguments.get("set_field", ""),
600
+ arguments.get("value", ""),
601
+ ))
602
+
603
+ elif name == "bingo_patch_squash":
604
+ return _result(repo.patch_squash(
605
+ arguments["index1"],
606
+ arguments["index2"],
607
+ ))
608
+
609
+ elif name == "bingo_patch_reorder":
610
+ return _result(repo.patch_reorder(
611
+ arguments.get("order", ""),
612
+ ))
613
+
614
+ elif name == "bingo_workspace_init":
615
+ return _result(repo.workspace_init())
616
+
617
+ elif name == "bingo_workspace_add":
618
+ return _result(repo.workspace_add(
619
+ arguments["path"],
620
+ arguments.get("alias", ""),
621
+ ))
622
+
623
+ elif name == "bingo_workspace_list":
624
+ return _result(repo.workspace_list())
625
+
626
+ elif name == "bingo_workspace_sync":
627
+ return _result(repo.workspace_sync())
628
+
629
+ elif name == "bingo_workspace_status":
630
+ return _result(repo.workspace_status())
631
+
632
+ else:
633
+ return {
634
+ "content": [{"type": "text", "text": f"Unknown tool: {name}"}],
635
+ "isError": True,
636
+ }
637
+
638
+ except BingoError as e:
639
+ return _result({"ok": False, "error": str(e)})
640
+
641
+ except Exception as e:
642
+ return _result({"ok": False, "error": f"Internal error: {e}"})
643
+
644
+ # ─── MCP JSON-RPC Protocol ───────────────────────────────────────────────────
645
+
646
+ _PARSE_ERROR = object() # Sentinel: bad message, but not EOF
647
+
648
+
649
+ def read_message():
650
+ """Read a JSON-RPC message from stdin (MCP stdio transport).
651
+
652
+ MCP spec defines stdio as newline-delimited JSON (every version since
653
+ 2024-11-05). All major clients (Claude Code, Cursor, Windsurf, Cline,
654
+ Continue, Roo Code, Gemini CLI, Codex CLI, Zed, JetBrains, GitHub
655
+ Copilot) send bare JSON lines.
656
+
657
+ Also supports Content-Length header framing (LSP-style) as a fallback
658
+ for compatibility with older test infrastructure.
659
+
660
+ Returns dict on success, None on EOF, _PARSE_ERROR on bad input.
661
+ """
662
+ line = sys.stdin.readline()
663
+ if not line:
664
+ return None # EOF
665
+
666
+ stripped = line.strip()
667
+ if not stripped:
668
+ return _PARSE_ERROR # Empty line — skip
669
+
670
+ # Standard MCP: newline-delimited JSON
671
+ if stripped.startswith("{"):
672
+ try:
673
+ return json.loads(stripped)
674
+ except json.JSONDecodeError:
675
+ return _PARSE_ERROR
676
+
677
+ # Fallback: Content-Length header framing (LSP-style)
678
+ global _use_content_length
679
+ _use_content_length = True
680
+ headers = {}
681
+ if ":" in stripped:
682
+ key, value = stripped.split(":", 1)
683
+ headers[key.strip().lower()] = value.strip()
684
+
685
+ while True:
686
+ hline = sys.stdin.readline()
687
+ if not hline:
688
+ return None
689
+ hline = hline.strip()
690
+ if hline == "":
691
+ break
692
+ if ":" in hline:
693
+ key, value = hline.split(":", 1)
694
+ headers[key.strip().lower()] = value.strip()
695
+
696
+ try:
697
+ content_length = int(headers.get("content-length", 0))
698
+ except (ValueError, TypeError):
699
+ return _PARSE_ERROR
700
+ if content_length <= 0 or content_length > 10 * 1024 * 1024:
701
+ return _PARSE_ERROR
702
+
703
+ body = sys.stdin.read(content_length)
704
+ if len(body) < content_length:
705
+ return _PARSE_ERROR
706
+ try:
707
+ return json.loads(body)
708
+ except json.JSONDecodeError:
709
+ return _PARSE_ERROR
710
+
711
+
712
+ # Framing mode: newline-delimited JSON by default (MCP spec),
713
+ # switches to Content-Length if client sends headers.
714
+ _use_content_length = False
715
+
716
+
717
+ def send_message(msg: dict):
718
+ """Write a JSON-RPC message to stdout (MCP stdio transport)."""
719
+ body = json.dumps(msg)
720
+ if _use_content_length:
721
+ sys.stdout.write(f"Content-Length: {len(body.encode())}\r\n\r\n")
722
+ sys.stdout.write(body)
723
+ else:
724
+ sys.stdout.write(body + "\n")
725
+ sys.stdout.flush()
726
+
727
+
728
+ def make_response(id, result):
729
+ return {"jsonrpc": "2.0", "id": id, "result": result}
730
+
731
+
732
+ def make_error(id, code, message):
733
+ return {"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}
734
+
735
+
736
+ def main():
737
+ """Main MCP server loop."""
738
+ while True:
739
+ msg = read_message()
740
+ if msg is None:
741
+ break # EOF — client disconnected
742
+ if msg is _PARSE_ERROR:
743
+ continue # Skip malformed message, keep serving
744
+
745
+ method = msg.get("method", "")
746
+ id = msg.get("id")
747
+ params = msg.get("params", {})
748
+
749
+ if method == "initialize":
750
+ # Echo back the client's protocol version for compatibility
751
+ client_version = params.get("protocolVersion", "2024-11-05")
752
+ send_message(make_response(id, {
753
+ "protocolVersion": client_version,
754
+ "capabilities": {"tools": {}},
755
+ "serverInfo": {
756
+ "name": "bingo-light",
757
+ "version": "2.0.0",
758
+ },
759
+ }))
760
+
761
+ elif method == "notifications/initialized":
762
+ pass # No response needed for notifications
763
+
764
+ elif method == "tools/list":
765
+ send_message(make_response(id, {"tools": TOOLS}))
766
+
767
+ elif method == "tools/call":
768
+ if id is None:
769
+ continue # JSON-RPC notification — must not respond
770
+ tool_name = params.get("name", "")
771
+ arguments = params.get("arguments", {})
772
+ try:
773
+ result = handle_tool_call(tool_name, arguments)
774
+ except Exception as e:
775
+ result = {"content": [{"type": "text", "text": f"Internal error: {e}"}], "isError": True}
776
+ send_message(make_response(id, result))
777
+
778
+ elif method == "ping":
779
+ if id is not None:
780
+ send_message(make_response(id, {}))
781
+
782
+ elif id is not None:
783
+ send_message(make_error(id, -32601, f"Method not found: {method}"))
784
+ # else: unknown notification, ignore per JSON-RPC 2.0 spec
785
+
786
+
787
+ if __name__ == "__main__":
788
+ main()