@hoanghainh/komagu 2.6.1 → 2.7.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.
@@ -7,7 +7,20 @@ import sys
7
7
  from typing import List, Optional
8
8
 
9
9
  from operate.cli import __version__
10
- from operate.cli.commands import demo, devsecops, evidence, fix, init, inventory, leak_check, protect, scan, simulate, test
10
+ from operate.cli.commands import (
11
+ demo,
12
+ devsecops,
13
+ evidence,
14
+ fix,
15
+ init,
16
+ inventory,
17
+ leak_check,
18
+ mcp,
19
+ protect,
20
+ scan,
21
+ simulate,
22
+ test,
23
+ )
11
24
  from operate.cli.ui import print_banner
12
25
 
13
26
 
@@ -30,6 +43,7 @@ Quick Start Workflow:
30
43
  9. Reconcile API inventory: komagu inventory --routes OUT/routes.json --openapi openapi.json
31
44
  10. Bundle METI SCS evidence: komagu evidence --audit-dir OUT/ --output-dir ./evidence-pack
32
45
  11. Check outputs for leaks: komagu leak-check OUT/ --canary-file canaries.txt
46
+ 12. Serve tools to an AI client: komagu mcp --stdio
33
47
 
34
48
  Documentation & Guides:
35
49
  Comprehensive User Guide: docs/cli-guide.md
@@ -68,6 +82,7 @@ Learn more about a specific command:
68
82
  inventory.register_subcommand(subparsers)
69
83
  evidence.register_subcommand(subparsers)
70
84
  leak_check.register_subcommand(subparsers)
85
+ mcp.register_subcommand(subparsers)
71
86
 
72
87
  help_parser = subparsers.add_parser(
73
88
  "help",
@@ -77,8 +92,24 @@ Learn more about a specific command:
77
92
  help_parser.add_argument(
78
93
  "topic",
79
94
  nargs="?",
80
- choices=["scan", "protect", "test", "demo", "devsecops", "fix", "init", "simulate", "inventory", "evidence", "leak-check"],
81
- help="Subcommand topic to display help for (scan, protect, test, demo, devsecops, fix, init, simulate, inventory, evidence, leak-check)",
95
+ choices=[
96
+ "scan",
97
+ "protect",
98
+ "test",
99
+ "demo",
100
+ "devsecops",
101
+ "fix",
102
+ "init",
103
+ "simulate",
104
+ "inventory",
105
+ "evidence",
106
+ "leak-check",
107
+ "mcp",
108
+ ],
109
+ help=(
110
+ "Subcommand topic to display help for "
111
+ "(scan, protect, test, demo, devsecops, fix, init, simulate, inventory, evidence, leak-check, mcp)"
112
+ ),
82
113
  )
83
114
 
84
115
  return parser
@@ -90,7 +121,14 @@ def main(argv: Optional[List[str]] = None) -> int:
90
121
  args = parser.parse_args(argv)
91
122
 
92
123
  is_json = getattr(args, "format", "") == "json"
93
- if not getattr(args, "no_banner", False) and getattr(args, "subcommand", None) is not None and not is_json:
124
+ # The MCP server owns stdout. A banner would break the JSON-RPC stream.
125
+ suppress_banner = getattr(args, "subcommand", None) == "mcp"
126
+ if (
127
+ not getattr(args, "no_banner", False)
128
+ and getattr(args, "subcommand", None) is not None
129
+ and not is_json
130
+ and not suppress_banner
131
+ ):
94
132
  print_banner()
95
133
 
96
134
  if getattr(args, "subcommand", None) is None:
@@ -0,0 +1,11 @@
1
+ """Zero-dependency Model Context Protocol server for the Komagu CLI.
2
+
3
+ Python 3.8+ standard library only. Messages are newline-delimited JSON-RPC
4
+ on stdio. Nothing except protocol messages is written to stdout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from operate.mcp.server import serve_stdio
10
+
11
+ __all__ = ["serve_stdio"]
@@ -0,0 +1,206 @@
1
+ """Newline-delimited JSON-RPC session for the Model Context Protocol.
2
+
3
+ Supported protocol revisions: 2024-11-05, 2025-03-26, and 2025-06-18.
4
+ A requested revision we implement is echoed back. Any other revision is
5
+ answered with the newest one this server implements.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import traceback
13
+ from typing import Any, Dict, IO, Optional, TextIO
14
+
15
+ from operate.cli import __version__
16
+ from operate.mcp.tools import INSTRUCTIONS, ToolError, ToolRegistry, build_registry
17
+
18
+ JSONRPC = "2.0"
19
+ PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18")
20
+ LATEST_PROTOCOL = PROTOCOL_VERSIONS[-1]
21
+ SERVER_NAME = "komagu"
22
+ MAX_LINE_CHARS = 1_048_576
23
+ TEXT_CAP = 60_000
24
+
25
+ PARSE_ERROR = -32700
26
+ INVALID_REQUEST = -32600
27
+ METHOD_NOT_FOUND = -32601
28
+ INVALID_PARAMS = -32602
29
+ INTERNAL_ERROR = -32603
30
+
31
+
32
+ def error_response(request_id: Any, code: int, message: str) -> Dict[str, Any]:
33
+ """JSON-RPC error object. request_id is null when the request id could not be read."""
34
+ return {"jsonrpc": JSONRPC, "id": request_id, "error": {"code": code, "message": message}}
35
+
36
+
37
+ def success_response(request_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
38
+ """JSON-RPC result object."""
39
+ return {"jsonrpc": JSONRPC, "id": request_id, "result": result}
40
+
41
+
42
+ def tool_result(payload: Dict[str, Any], is_error: bool) -> Dict[str, Any]:
43
+ """MCP tools/call result. Text is the payload the client shows the model."""
44
+ if is_error:
45
+ text = payload.get("message", "tool error")
46
+ return {"content": [{"type": "text", "text": text}], "isError": True}
47
+ text = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
48
+ if len(text) > TEXT_CAP:
49
+ text = text[:TEXT_CAP] + "\n…truncated"
50
+ return {
51
+ "content": [{"type": "text", "text": text}],
52
+ "structuredContent": payload,
53
+ "isError": False,
54
+ }
55
+
56
+
57
+ class Session:
58
+ """One stdio client. Requests other than initialize and ping wait for initialized."""
59
+
60
+ def __init__(self, registry: ToolRegistry, version: str, instructions: str) -> None:
61
+ self.registry = registry
62
+ self.version = version
63
+ self.instructions = instructions
64
+ self.initialized = False
65
+ self.negotiated: Optional[str] = None
66
+
67
+ def handle(self, message: Any) -> Optional[Dict[str, Any]]:
68
+ """Return a response, or None when the message is a notification."""
69
+ if not isinstance(message, dict):
70
+ return error_response(None, INVALID_REQUEST, "Invalid Request")
71
+
72
+ is_request = "id" in message
73
+ request_id = message.get("id") if is_request else None
74
+ if message.get("jsonrpc") != JSONRPC:
75
+ return error_response(request_id, INVALID_REQUEST, "Invalid Request") if is_request else None
76
+
77
+ method = message.get("method")
78
+ if not isinstance(method, str) or not method:
79
+ return error_response(request_id, INVALID_REQUEST, "Invalid Request") if is_request else None
80
+
81
+ params = message.get("params", {})
82
+ if params is None:
83
+ params = {}
84
+ if not isinstance(params, dict):
85
+ return error_response(request_id, INVALID_PARAMS, "Invalid params") if is_request else None
86
+
87
+ if method == "initialize":
88
+ if not is_request:
89
+ return None
90
+ return self._initialize(request_id, params)
91
+
92
+ if method in ("notifications/initialized", "initialized"):
93
+ if self.negotiated is not None:
94
+ self.initialized = True
95
+ return None
96
+
97
+ if method == "ping":
98
+ return success_response(request_id, {}) if is_request else None
99
+
100
+ if method.startswith("notifications/"):
101
+ return None
102
+
103
+ if not is_request:
104
+ return None
105
+
106
+ if not self.initialized:
107
+ return error_response(request_id, INVALID_REQUEST, "Server not initialized")
108
+
109
+ if method == "tools/list":
110
+ cursor = params.get("cursor")
111
+ if cursor not in (None, ""):
112
+ return error_response(request_id, INVALID_PARAMS, "Unknown cursor")
113
+ return success_response(request_id, {"tools": self.registry.descriptors()})
114
+
115
+ if method == "tools/call":
116
+ return self._call_tool(request_id, params)
117
+
118
+ if method == "logging/setLevel":
119
+ return success_response(request_id, {})
120
+
121
+ return error_response(request_id, METHOD_NOT_FOUND, f"Method not found: {method}")
122
+
123
+ def _initialize(self, request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
124
+ if self.negotiated is not None:
125
+ return error_response(request_id, INVALID_REQUEST, "Server already initialized")
126
+ version = params.get("protocolVersion")
127
+ if not isinstance(version, str) or not version:
128
+ return error_response(request_id, INVALID_PARAMS, "protocolVersion is required")
129
+ chosen = version if version in PROTOCOL_VERSIONS else LATEST_PROTOCOL
130
+ self.negotiated = chosen
131
+ return success_response(
132
+ request_id,
133
+ {
134
+ "protocolVersion": chosen,
135
+ "capabilities": {"tools": {"listChanged": False}},
136
+ "serverInfo": {"name": SERVER_NAME, "version": self.version},
137
+ "instructions": self.instructions,
138
+ },
139
+ )
140
+
141
+ def _call_tool(self, request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
142
+ name = params.get("name")
143
+ if not isinstance(name, str) or not name:
144
+ return error_response(request_id, INVALID_PARAMS, "tool name is required")
145
+ arguments = params.get("arguments", {})
146
+ if arguments is None:
147
+ arguments = {}
148
+ if not isinstance(arguments, dict):
149
+ return error_response(request_id, INVALID_PARAMS, "arguments must be an object")
150
+ try:
151
+ payload = self.registry.call(name, arguments)
152
+ except KeyError:
153
+ return error_response(request_id, INVALID_PARAMS, f"Unknown tool: {name}")
154
+ except ToolError as exc:
155
+ return success_response(request_id, tool_result({"message": str(exc)}, is_error=True))
156
+ except Exception as exc:
157
+ traceback.print_exc(file=sys.stderr)
158
+ return success_response(request_id, tool_result({"message": f"Tool failed: {exc}"}, is_error=True))
159
+ return success_response(request_id, tool_result(payload, is_error=False))
160
+
161
+
162
+ def _write(stdout: TextIO, message: Dict[str, Any]) -> None:
163
+ """One JSON object per line. ensure_ascii keeps the line free of raw separators."""
164
+ stdout.write(json.dumps(message, ensure_ascii=True, separators=(",", ":")) + "\n")
165
+ flush = getattr(stdout, "flush", None)
166
+ if flush is not None:
167
+ flush()
168
+
169
+
170
+ def serve(session: Session, stdin: IO[str], stdout: TextIO) -> None:
171
+ """Read messages until stdin closes. Blank lines are ignored."""
172
+ while True:
173
+ line = stdin.readline()
174
+ if line == "":
175
+ return
176
+ if len(line) > MAX_LINE_CHARS:
177
+ _write(stdout, error_response(None, INVALID_REQUEST, "Message too large"))
178
+ continue
179
+ if not line.strip():
180
+ continue
181
+ try:
182
+ message = json.loads(line)
183
+ except json.JSONDecodeError:
184
+ _write(stdout, error_response(None, PARSE_ERROR, "Parse error"))
185
+ continue
186
+ if isinstance(message, list):
187
+ _write(stdout, error_response(None, INVALID_REQUEST, "Batch requests are not supported"))
188
+ continue
189
+ response = session.handle(message)
190
+ if response is not None:
191
+ _write(stdout, response)
192
+
193
+
194
+ def serve_stdio() -> int:
195
+ """Serve on process stdin and stdout. Logs go to stderr."""
196
+ if hasattr(sys.stdin, "reconfigure"):
197
+ sys.stdin.reconfigure(encoding="utf-8")
198
+ if hasattr(sys.stdout, "reconfigure"):
199
+ sys.stdout.reconfigure(encoding="utf-8", line_buffering=True)
200
+ print("komagu mcp: serving Model Context Protocol on stdio", file=sys.stderr, flush=True)
201
+ session = Session(build_registry(), __version__, INSTRUCTIONS)
202
+ try:
203
+ serve(session, sys.stdin, sys.stdout)
204
+ except BrokenPipeError:
205
+ return 0
206
+ return 0