@hoanghainh/komagu 2.6.0 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
  4. package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
  5. package/assess/lsa/scripts/find_path_ssrf.py +176 -0
  6. package/assess/lsa/scripts/findings_to_sarif.py +29 -0
  7. package/assess/lsa/scripts/merge_findings.py +1 -1
  8. package/assess/lsa/scripts/run_prescan.py +4 -0
  9. package/assess/lsa/tests/run_tests.py +4 -0
  10. package/assess/lsa/tests/test_path_ssrf.py +94 -0
  11. package/contracts/schemas/finding.schema.json +4 -2
  12. package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
  13. package/examples/member-portal/backend-dotnet/Program.cs +1 -72
  14. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
  15. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
  16. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
  17. package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
  18. package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
  19. package/operate/README.md +6 -1
  20. package/operate/ai/client.py +5 -1
  21. package/operate/cli/__init__.py +1 -1
  22. package/operate/cli/commands/fix.py +47 -0
  23. package/operate/cli/commands/mcp.py +54 -0
  24. package/operate/cli/main.py +42 -4
  25. package/operate/cross_user_tester/runner.py +4 -1
  26. package/operate/devsecops/sbom.py +12 -4
  27. package/operate/mcp/__init__.py +11 -0
  28. package/operate/mcp/server.py +206 -0
  29. package/operate/mcp/tools.py +464 -0
  30. package/operate/remediator/ai_fixer.py +285 -0
  31. package/operate/remediator/runner.py +11 -0
  32. package/operate/remediator/strategies.py +64 -0
  33. package/operate/simulate/scenarios.py +11 -2
  34. package/operate/tests/test_ai_remediation.py +288 -0
  35. package/operate/tests/test_audit_minimum_versions.py +89 -0
  36. package/operate/tests/test_cli.py +1 -1
  37. package/operate/tests/test_mcp_server.py +380 -0
  38. package/operate/tests/test_scheme_and_xml_guards.py +67 -0
  39. package/package.json +1 -1
  40. package/remediate/authz/bfla_patcher.py +319 -0
  41. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
  42. package/remediate/authz/golang/authz_test.go +5 -2
  43. package/remediate/authz/mass_assignment_patcher.py +188 -0
  44. package/remediate/authz/patcher.py +8 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
  47. package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
  48. package/remediate/deps/patcher.py +2 -1
  49. package/remediate/edge/mock-backend/app.py +2 -1
  50. package/remediate/edge/tests/test_edge_security.py +5 -5
  51. package/remediate/ekyc/requirements.txt +1 -1
  52. package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
  53. package/remediate/identity/python/requirements.txt +2 -2
  54. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
  55. package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
  56. package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
  57. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
  58. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
@@ -8,6 +8,8 @@ import java.math.BigDecimal
8
8
  import java.time.Instant
9
9
  import java.util.UUID
10
10
 
11
+ // JPA entity: one constructor parameter per column.
12
+ @Suppress("LongParameterList")
11
13
  @Entity
12
14
  @Table(name = "portal_orders")
13
15
  class OrderEntity(
@@ -21,6 +23,8 @@ class OrderEntity(
21
23
  var createdAt: Instant = Instant.now()
22
24
  ) : OwnedResource<String>
23
25
 
26
+ // JPA entity: one constructor parameter per column.
27
+ @Suppress("LongParameterList")
24
28
  @Entity
25
29
  @Table(name = "portal_documents")
26
30
  class DocumentEntity(
@@ -86,7 +86,7 @@ class TestDemoAppVerification(unittest.TestCase):
86
86
  health_url = f"{cls.base_url}/api/v1/auth/personas"
87
87
  for _ in range(50):
88
88
  try:
89
- with urllib.request.urlopen(health_url, timeout=1.0) as resp:
89
+ with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
90
90
  if resp.status == 200:
91
91
  ready = True
92
92
  break
@@ -124,7 +124,7 @@ class TestDemoAppVerification(unittest.TestCase):
124
124
  url,
125
125
  headers={"Authorization": "Bearer token-user-a"},
126
126
  )
127
- with urllib.request.urlopen(req, timeout=5) as resp:
127
+ with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
128
128
  self.assertEqual(resp.status, 200)
129
129
  data = json.loads(resp.read().decode("utf-8"))
130
130
  self.assertEqual(data["id"], "ord-001")
@@ -188,7 +188,7 @@ class TestDemoAppVerification(unittest.TestCase):
188
188
  headers={"Authorization": "Bearer token-user-c"},
189
189
  )
190
190
  with self.assertRaises(urllib.error.HTTPError) as ctx:
191
- urllib.request.urlopen(req, timeout=5)
191
+ urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
192
192
  self.assertEqual(ctx.exception.code, 404)
193
193
 
194
194
  def test_05_safe_sort_protects_against_sqli(self) -> None:
@@ -199,7 +199,7 @@ class TestDemoAppVerification(unittest.TestCase):
199
199
  headers={"Authorization": "Bearer token-user-a"},
200
200
  )
201
201
  with self.assertRaises(urllib.error.HTTPError) as ctx:
202
- urllib.request.urlopen(req, timeout=5)
202
+ urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
203
203
  # Server must reject with 400 or 500 SecurityException, not 200
204
204
  self.assertIn(ctx.exception.code, [400, 500])
205
205
 
@@ -88,7 +88,7 @@ class TestRemediationVerification(unittest.TestCase):
88
88
  health_url = f"{cls.base_url}/api/v1/auth/personas"
89
89
  for _ in range(50):
90
90
  try:
91
- with urllib.request.urlopen(health_url, timeout=1.0) as resp:
91
+ with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
92
92
  if resp.status == 200:
93
93
  ready = True
94
94
  break
@@ -126,7 +126,7 @@ class TestRemediationVerification(unittest.TestCase):
126
126
  url,
127
127
  headers={"Authorization": "Bearer token-user-a"},
128
128
  )
129
- with urllib.request.urlopen(req, timeout=5) as resp:
129
+ with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
130
130
  self.assertEqual(resp.status, 200)
131
131
  data = json.loads(resp.read().decode("utf-8"))
132
132
  self.assertEqual(data["id"], "ord-001")
@@ -169,7 +169,7 @@ class TestRemediationVerification(unittest.TestCase):
169
169
  headers={"Authorization": "Bearer token-user-b"},
170
170
  )
171
171
  with self.assertRaises(urllib.error.HTTPError) as ctx:
172
- urllib.request.urlopen(req, timeout=5)
172
+ urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
173
173
  self.assertEqual(ctx.exception.code, 404)
174
174
 
175
175
  def test_04_attacker_direct_put_unauthorized_returns_404(self) -> None:
@@ -186,7 +186,7 @@ class TestRemediationVerification(unittest.TestCase):
186
186
  method="PUT",
187
187
  )
188
188
  with self.assertRaises(urllib.error.HTTPError) as ctx:
189
- urllib.request.urlopen(req, timeout=5)
189
+ urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
190
190
  self.assertEqual(ctx.exception.code, 404)
191
191
 
192
192
  def test_05_attacker_direct_delete_unauthorized_returns_404(self) -> None:
@@ -198,7 +198,7 @@ class TestRemediationVerification(unittest.TestCase):
198
198
  method="DELETE",
199
199
  )
200
200
  with self.assertRaises(urllib.error.HTTPError) as ctx:
201
- urllib.request.urlopen(req, timeout=5)
201
+ urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
202
202
  self.assertEqual(ctx.exception.code, 404)
203
203
 
204
204
  def test_06_parameterized_search_neutralizes_sqli(self) -> None:
@@ -210,7 +210,7 @@ class TestRemediationVerification(unittest.TestCase):
210
210
  url,
211
211
  headers={"Authorization": "Bearer token-user-a"},
212
212
  )
213
- with urllib.request.urlopen(req, timeout=5) as resp:
213
+ with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
214
214
  self.assertEqual(resp.status, 200)
215
215
  data = json.loads(resp.read().decode("utf-8"))
216
216
  # When safely parameterized as LIKE '%...%', it searches for the literal string and matches nothing
package/operate/README.md CHANGED
@@ -22,7 +22,12 @@ This directory provides CI/CD components for automated security verification, pr
22
22
  - `github/cross-user-authz.yml`: Reusable GitHub Actions workflow.
23
23
  - `gitlab/cross-user-authz.gitlab-ci.yml`: GitLab CI security template.
24
24
 
25
- 4. **Automated Remediation Runner (`remediator/runner.py` / `komagu fix`)**:
25
+ 4. **MCP server (`operate/mcp/`, `komagu mcp --stdio`)**:
26
+ - Speaks the Model Context Protocol on stdin/stdout with the Python standard library only (no MCP SDK).
27
+ - Tools stay local: Tier 1 `scan`, `read_audit`, `leak_check`, `inventory`, and `fix_preview`.
28
+ - `fix_preview` is a dry run and does not modify source. Patch apply, the edge gateway, and live cross-user tests stay on the CLI.
29
+
30
+ 5. **Automated Remediation Runner (`remediator/runner.py` / `komagu fix`)**:
26
31
  - Parses audit findings (`findings.json` or SARIF) and generates automated code fixes for BOLA/IDOR and SQLi vulnerabilities.
27
32
  - Supports dry-run simulation, unified patch export, and direct in-place remediation.
28
33
 
@@ -9,6 +9,7 @@ import json
9
9
  import re
10
10
  import time
11
11
  import urllib.error
12
+ import urllib.parse
12
13
  import urllib.request
13
14
  from typing import Any, Dict, List, Optional, Tuple
14
15
 
@@ -29,6 +30,8 @@ class LLMClient:
29
30
  ) -> None:
30
31
  if reasoning_effort is not None and reasoning_effort not in REASONING_EFFORTS:
31
32
  raise ValueError(f"reasoning_effort must be one of {', '.join(REASONING_EFFORTS)}")
33
+ if urllib.parse.urlparse(endpoint).scheme.lower() not in ("http", "https"):
34
+ raise ValueError("LLM endpoint must be an http:// or https:// URL")
32
35
  raw_endpoint = endpoint.rstrip("/")
33
36
  if not raw_endpoint.endswith("/chat/completions"):
34
37
  self.endpoint = f"{raw_endpoint}/chat/completions"
@@ -58,7 +61,8 @@ class LLMClient:
58
61
  req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
59
62
  started = time.monotonic()
60
63
  try:
61
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
64
+ # The endpoint scheme is checked to be http(s) in __init__.
65
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
62
66
  body = resp.read().decode("utf-8")
63
67
  res_json = json.loads(body)
64
68
  self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.6.0" # x-release-please-version
5
+ __version__ = "2.7.0" # x-release-please-version
@@ -4,10 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
7
8
  import sys
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
12
+ from operate.ai.client import REASONING_EFFORTS
13
+ from operate.remediator.ai_fixer import AITier2Fixer
14
+
11
15
  from operate.cli.ui import (
12
16
  BOLD,
13
17
  CYAN,
@@ -89,6 +93,38 @@ Examples:
89
93
  default="summary",
90
94
  help="Output format: summary table (default), json, or diff",
91
95
  )
96
+ parser.add_argument(
97
+ "--ai",
98
+ action="store_true",
99
+ help="Enable Tier 2 AI-assisted multi-line fix fallback with verification gate when deterministic patchers cannot rewrite a finding",
100
+ )
101
+ parser.add_argument(
102
+ "--ai-endpoint",
103
+ default=os.environ.get("KOMAGU_AI_ENDPOINT", "http://localhost:11434/v1"),
104
+ help="OpenAI-compatible /v1 endpoint for --ai fixes (default: $KOMAGU_AI_ENDPOINT or http://localhost:11434/v1)",
105
+ )
106
+ parser.add_argument(
107
+ "--ai-model",
108
+ default=os.environ.get("KOMAGU_AI_MODEL", "llama3.1"),
109
+ help="Model name for --ai fixes (default: $KOMAGU_AI_MODEL or llama3.1)",
110
+ )
111
+ parser.add_argument(
112
+ "--ai-api-key",
113
+ default=os.environ.get("KOMAGU_AI_API_KEY") or os.environ.get("OPENAI_API_KEY"),
114
+ help="API key for --ai endpoint (default: $KOMAGU_AI_API_KEY or $OPENAI_API_KEY)",
115
+ )
116
+ parser.add_argument(
117
+ "--ai-timeout",
118
+ type=int,
119
+ default=120,
120
+ help="Timeout in seconds per --ai LLM request (default: 120)",
121
+ )
122
+ parser.add_argument(
123
+ "--ai-reasoning-effort",
124
+ choices=REASONING_EFFORTS,
125
+ default=None,
126
+ help="Reasoning effort passed to OpenAI-compatible endpoint (default: not sent)",
127
+ )
92
128
  parser.set_defaults(func=run_fix)
93
129
 
94
130
 
@@ -100,6 +136,16 @@ def run_fix(args: argparse.Namespace) -> int:
100
136
  step_fail(f"Target path does not exist: {target_path}")
101
137
  return 1
102
138
 
139
+ ai_fixer = None
140
+ if getattr(args, "ai", False):
141
+ ai_fixer = AITier2Fixer(
142
+ endpoint=args.ai_endpoint,
143
+ model=args.ai_model,
144
+ api_key=args.ai_api_key,
145
+ timeout=args.ai_timeout,
146
+ reasoning_effort=args.ai_reasoning_effort,
147
+ )
148
+
103
149
  runner = RemediationRunner(
104
150
  target_dir=target_path,
105
151
  findings_path=args.findings_path,
@@ -107,6 +153,7 @@ def run_fix(args: argparse.Namespace) -> int:
107
153
  output_patch=args.output_patch,
108
154
  scan_sca=getattr(args, "sca", False),
109
155
  query_osv=getattr(args, "osv", False),
156
+ ai_fixer=ai_fixer,
110
157
  )
111
158
 
112
159
  mode_label = "DRY-RUN (Simulated)" if args.dry_run else "APPLY (In-place)"
@@ -0,0 +1,54 @@
1
+ """Command: komagu mcp - zero-dependency Model Context Protocol server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def register_subcommand(subparsers: argparse._SubParsersAction) -> None:
10
+ """Register the mcp subcommand parser."""
11
+ parser = subparsers.add_parser(
12
+ "mcp",
13
+ help="Serve Komagu analysis tools over the Model Context Protocol on stdio",
14
+ description=(
15
+ "Speaks MCP JSON-RPC on stdin and stdout using the Python standard library only. "
16
+ "Tools are local: Tier 1 scan, audit summary, leak check, API inventory, and a dry-run fix preview. "
17
+ "The preview never modifies source. Logging is written to stderr so stdout stays a protocol stream."
18
+ ),
19
+ formatter_class=argparse.RawDescriptionHelpFormatter,
20
+ epilog="""
21
+ Examples:
22
+ # Cursor, Claude Code, or any MCP client that launches a stdio server
23
+ komagu mcp --stdio
24
+
25
+ Client configuration:
26
+ {
27
+ "mcpServers": {
28
+ "komagu": {
29
+ "command": "komagu",
30
+ "args": ["mcp", "--stdio"]
31
+ }
32
+ }
33
+ }
34
+ """,
35
+ )
36
+ parser.add_argument(
37
+ "--stdio",
38
+ action="store_true",
39
+ help="Speak newline-delimited MCP JSON-RPC on stdin/stdout (the only transport)",
40
+ )
41
+ parser.set_defaults(func=run_mcp)
42
+
43
+
44
+ def run_mcp(args: argparse.Namespace) -> int:
45
+ """Start the stdio server, or reject the command when --stdio was omitted."""
46
+ if not getattr(args, "stdio", False):
47
+ print(
48
+ "komagu mcp: the only transport is stdio. Run: komagu mcp --stdio",
49
+ file=sys.stderr,
50
+ )
51
+ return 2
52
+ from operate.mcp.server import serve_stdio
53
+
54
+ return serve_stdio()
@@ -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:
@@ -242,7 +242,10 @@ class CrossUserTestRunner:
242
242
  error_message = None
243
243
 
244
244
  try:
245
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
245
+ if urllib.parse.urlparse(url).scheme.lower() not in ("http", "https"):
246
+ raise ValueError("base URL must be an http:// or https:// URL")
247
+ # The scheme is checked just above.
248
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
246
249
  status_code = resp.status
247
250
  raw = resp.read()
248
251
  response_body = raw.decode("utf-8", errors="replace")[:1000]
@@ -24,6 +24,16 @@ import xml.etree.ElementTree as ET
24
24
  from typing import Any, Dict, List, Optional
25
25
 
26
26
 
27
+ def _parse_xml_without_dtd(file_path: str) -> ET.Element:
28
+ """Parse a manifest from the scanned project, refusing DTDs (entity expansion)."""
29
+ with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
30
+ text = handle.read()
31
+ if "<!DOCTYPE" in text.upper() or "<!ENTITY" in text.upper():
32
+ raise ValueError(f"{file_path}: XML with a DTD is not parsed")
33
+ # DTDs are refused above, so no entity can be declared or expanded.
34
+ return ET.fromstring(text) # nosec B314
35
+
36
+
27
37
  class Component:
28
38
  """Represents a software package or component in an SBOM."""
29
39
 
@@ -195,8 +205,7 @@ class SBOMGenerator:
195
205
  def _parse_csproj(self, file_path: str) -> List[Component]:
196
206
  components = []
197
207
  try:
198
- tree = ET.parse(file_path)
199
- root = tree.getroot()
208
+ root = _parse_xml_without_dtd(file_path)
200
209
  for pkg in root.findall(".//PackageReference"):
201
210
  name = pkg.get("Include") or pkg.get("Update")
202
211
  version = pkg.get("Version")
@@ -213,8 +222,7 @@ class SBOMGenerator:
213
222
  def _parse_pom_xml(self, file_path: str) -> List[Component]:
214
223
  components = []
215
224
  try:
216
- tree = ET.parse(file_path)
217
- root = tree.getroot()
225
+ root = _parse_xml_without_dtd(file_path)
218
226
  ns = {"m": "http://maven.apache.org/POM/4.0.0"}
219
227
  # Search both with namespace and without
220
228
  deps = root.findall(".//m:dependency", ns) or root.findall(".//dependency")
@@ -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