agentseed-mcp 0.3.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 (76) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/LICENSE +202 -0
  3. package/README.ja.md +320 -0
  4. package/README.md +318 -0
  5. package/README.zh.md +306 -0
  6. package/bin/cli.js +37 -0
  7. package/mcp.json +12 -0
  8. package/package.json +30 -0
  9. package/plugin.json +22 -0
  10. package/server/.agentseed/verification-log.jsonl +2 -0
  11. package/server/__pycache__/guard_cli.cpython-313.pyc +0 -0
  12. package/server/__pycache__/guard_engine.cpython-313.pyc +0 -0
  13. package/server/__pycache__/test_cli.cpython-313-pytest-9.1.1.pyc +0 -0
  14. package/server/__pycache__/test_cli.cpython-313.pyc +0 -0
  15. package/server/__pycache__/test_features.cpython-313-pytest-9.1.1.pyc +0 -0
  16. package/server/__pycache__/test_features.cpython-313.pyc +0 -0
  17. package/server/__pycache__/test_guard.cpython-313-pytest-9.1.1.pyc +0 -0
  18. package/server/__pycache__/test_guard.cpython-313.pyc +0 -0
  19. package/server/__pycache__/test_hook.cpython-313-pytest-9.1.1.pyc +0 -0
  20. package/server/__pycache__/test_hook.cpython-313.pyc +0 -0
  21. package/server/__pycache__/test_manifests.cpython-313-pytest-9.1.1.pyc +0 -0
  22. package/server/__pycache__/test_manifests.cpython-313.pyc +0 -0
  23. package/server/__pycache__/test_server.cpython-313-pytest-9.1.1.pyc +0 -0
  24. package/server/__pycache__/test_server.cpython-313.pyc +0 -0
  25. package/server/engine/__init__.py +64 -0
  26. package/server/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/server/engine/__pycache__/audit.cpython-313.pyc +0 -0
  28. package/server/engine/__pycache__/config.cpython-313.pyc +0 -0
  29. package/server/engine/__pycache__/hallucination.cpython-313.pyc +0 -0
  30. package/server/engine/__pycache__/imports.cpython-313.pyc +0 -0
  31. package/server/engine/__pycache__/plugin.cpython-313.pyc +0 -0
  32. package/server/engine/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/server/engine/__pycache__/schema.cpython-313.pyc +0 -0
  34. package/server/engine/__pycache__/symbols.cpython-313.pyc +0 -0
  35. package/server/engine/__pycache__/version.cpython-313.pyc +0 -0
  36. package/server/engine/audit.py +84 -0
  37. package/server/engine/config.py +131 -0
  38. package/server/engine/hallucination.py +254 -0
  39. package/server/engine/imports.py +136 -0
  40. package/server/engine/plugin.py +367 -0
  41. package/server/engine/sandbox.py +287 -0
  42. package/server/engine/schema.py +193 -0
  43. package/server/engine/symbols.py +984 -0
  44. package/server/engine/version.py +17 -0
  45. package/server/guard_cli.py +455 -0
  46. package/server/guard_engine.py +111 -0
  47. package/server/guard_hook.py +404 -0
  48. package/server/guard_server.py +472 -0
  49. package/server/requirements.txt +7 -0
  50. package/server/test_cli.py +132 -0
  51. package/server/test_features.py +426 -0
  52. package/server/test_guard.py +828 -0
  53. package/server/test_hook.py +331 -0
  54. package/server/test_manifests.py +70 -0
  55. package/server/test_server.py +247 -0
  56. package/skills/verify-before-code/SKILL.ja.md +116 -0
  57. package/skills/verify-before-code/SKILL.md +140 -0
  58. package/skills/verify-before-code/SKILL.zh.md +117 -0
  59. package/skills/verify-before-code/references/DEFAULT-NORMS.md +52 -0
  60. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.ja.md +121 -0
  61. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.md +166 -0
  62. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.zh.md +145 -0
  63. package/skills/verify-before-code/references/PROMPT-POOL.ja.md +248 -0
  64. package/skills/verify-before-code/references/PROMPT-POOL.md +282 -0
  65. package/skills/verify-before-code/references/PROMPT-POOL.zh.md +252 -0
  66. package/skills/verify-before-code/references/SDD-CONTRACT.ja.md +61 -0
  67. package/skills/verify-before-code/references/SDD-CONTRACT.md +66 -0
  68. package/skills/verify-before-code/references/SDD-CONTRACT.zh.md +58 -0
  69. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.ja.md +62 -0
  70. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.md +62 -0
  71. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.zh.md +54 -0
  72. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.ja.md +68 -0
  73. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.md +73 -0
  74. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.zh.md +68 -0
  75. package/skills/verify-before-code/scripts/check.ps1 +52 -0
  76. package/skills/verify-before-code/scripts/check.sh +44 -0
@@ -0,0 +1,472 @@
1
+ """AgentSeed MCP server (hand-written, zero third-party dependencies).
2
+
3
+ Why hand-written instead of the `mcp` SDK: the SDK API drifts between
4
+ releases (e.g. `list_tools`/`call_tool` decorators were removed in newer
5
+ versions). A minimal JSON-RPC 2.0 stdio implementation over the standard
6
+ library works against Cursor / VS Code / Claude Code / Copilot regardless of
7
+ which MCP SDK version the client ships.
8
+
9
+ Protocol: line-delimited JSON-RPC 2.0 over stdin/stdout (stdio transport).
10
+
11
+ Tools:
12
+ - verify_code -> detect_undefined_symbols
13
+ - scan_hallucination -> scan_hallucination_words
14
+ - check_plugin -> check_plugin_conformance
15
+ - sandbox_run -> deterministic command execution (verification channel)
16
+ - schema_validate -> JSON Schema validation (jsonschema when installed,
17
+ built-in subset fallback)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ import threading
25
+
26
+ import guard_engine as engine # noqa: E402
27
+
28
+
29
+ VERSION = engine.plugin_version()
30
+
31
+ # Protocol versions this server can speak. initialize() echoes the client's
32
+ # request when we support it, otherwise falls back to our baseline.
33
+ SUPPORTED_PROTOCOL = {"2024-11-05", "2025-03-26", "2025-06-18"}
34
+ BASELINE_PROTOCOL = "2024-11-05"
35
+
36
+ # In-flight cancellable requests (MCP notifications/cancelled support).
37
+ _pending_lock = threading.Lock()
38
+ _pending: dict = {} # request id -> {"proc": Popen | None, "cancelled": bool}
39
+ _stdout_lock = threading.Lock()
40
+
41
+ # Loaded once at startup: AGENTSEED_CONFIG env, ${PLUGIN_DATA}/
42
+ # agentseed.config.json (Agent Plugins v1.0.0 §9.1), or ./agentseed.config.json.
43
+ CONFIG = engine.load_config()
44
+ CONFIG_ALLOWLIST = engine.config_str_list(CONFIG, "allowlist")
45
+ CONFIG_SEVERITIES = engine.config_severities(CONFIG)
46
+ CONFIG_TIMEOUT = engine.parse_timeout(CONFIG)
47
+ CONFIG_EXTRA_TOKENS = engine.config_extra_tokens(CONFIG)
48
+ CONFIG_SUPPRESS = engine.config_str_list(CONFIG, "suppress_symbols")
49
+ CONFIG_KNOWN_PACKAGES = engine.config_str_list(CONFIG, "known_packages")
50
+ CONFIG_SANDBOX_ALLOW = engine.config_str_list(CONFIG, "sandbox_allowed_prefixes")
51
+ CONFIG_SANDBOX_ENV = engine.sandbox_env_mode(CONFIG)
52
+
53
+ for _warn_key in engine.unknown_config_keys(CONFIG):
54
+ print(
55
+ f"[agentseed] WARNING: unknown config key '{_warn_key}' ignored "
56
+ f"(known keys: {sorted(engine.KNOWN_CONFIG_KEYS)})",
57
+ file=sys.stderr,
58
+ )
59
+
60
+
61
+ def _tool(name: str, description: str, props: dict, required: list[str]) -> dict:
62
+ return {
63
+ "name": name,
64
+ "description": description,
65
+ "inputSchema": {
66
+ "type": "object",
67
+ "properties": props,
68
+ "required": required,
69
+ },
70
+ }
71
+
72
+
73
+ TOOLS = [
74
+ _tool(
75
+ "verify_code",
76
+ "Static analysis to flag symbols the model may have hallucinated "
77
+ "(called/used but never defined or imported). python uses full AST "
78
+ "analysis; typescript/javascript use a lexical pass; go, rust, java, "
79
+ "c, c++, c#, php, ruby, kotlin, swift are analyzed by a config-driven "
80
+ "generic lexical engine (extensible — any language can be added via "
81
+ "the language registry). Use before marking a coding task complete. "
82
+ "Returns 'suspects' plus per-symbol 'suspects_detail' line numbers; "
83
+ "names listed in config 'suppress_symbols' are excluded but reported "
84
+ "in 'suppressed'.",
85
+ {
86
+ "source": {"type": "string", "description": "Source code to analyze."},
87
+ "language": {
88
+ "type": "string",
89
+ "description": "Source language: python | typescript | javascript | "
90
+ "go | rust | java | c | c++ | c# | php | ruby | kotlin | swift "
91
+ "(aliases accepted).",
92
+ "default": "python",
93
+ },
94
+ },
95
+ ["source"],
96
+ ),
97
+ _tool(
98
+ "check_contract",
99
+ "Verify source against a declared contract: 'requires' names must be "
100
+ "defined or imported by the source, 'prohibits' tokens must not "
101
+ "appear. Contract is a JSON string, e.g. "
102
+ '{"requires": ["run"], "prohibits": ["stub"]}. Same language set as '
103
+ "verify_code. Use when a task must satisfy a written spec, not just "
104
+ "avoid undefined symbols.",
105
+ {
106
+ "source": {"type": "string", "description": "Source code to analyze."},
107
+ "contract": {
108
+ "type": "string",
109
+ "description": 'JSON contract: {"requires": [...], "prohibits": [...]}.',
110
+ },
111
+ "language": {
112
+ "type": "string",
113
+ "description": "Source language (same set as verify_code).",
114
+ "default": "python",
115
+ },
116
+ },
117
+ ["source", "contract"],
118
+ ),
119
+ _tool(
120
+ "check_imports",
121
+ "Flag top-level imports that are neither Python stdlib nor in the "
122
+ "known-package set (stdlib + common third-party + config "
123
+ "'known_packages') — a possible hallucinated package (slopsquatting, "
124
+ "USENIX Security 2025). Report, not a gate: verify the name exists in "
125
+ "the registry before installing. Python only; other languages return "
126
+ "an empty result.",
127
+ {
128
+ "source": {"type": "string", "description": "Source code to analyze."},
129
+ "language": {
130
+ "type": "string",
131
+ "description": "Source language (python supported; others return empty).",
132
+ "default": "python",
133
+ },
134
+ },
135
+ ["source"],
136
+ ),
137
+ _tool(
138
+ "scan_hallucination",
139
+ "Scan source for hallucination signals in three groups: stub_code "
140
+ "(stub/mock/fake/placeholder/todo/占位/待实现/...), oversold "
141
+ "(guaranteed/all tests pass/production ready/保证通过/...), fabricated "
142
+ "(simulated/invented/虚构/编造/...). English AND CJK tokens; extend "
143
+ "the pool via config 'extra_tokens'. Each hit carries a severity; "
144
+ "only error-severity hits set 'blocking': true. A blocking result "
145
+ "means the task is NOT done — fix the flagged lines or downgrade "
146
+ "deliberately via config. Warning/info hits must be reported but do "
147
+ "not block completion.",
148
+ {
149
+ "source": {"type": "string", "description": "Source code to scan."},
150
+ "allowlist": {
151
+ "type": "array",
152
+ "items": {"type": "string"},
153
+ "description": "Optional case-insensitive prefixes to exclude "
154
+ "from matching (e.g. ['Mock(']). Defaults to built-in test-idiom "
155
+ "exclusions; pass [] to disable all exclusions.",
156
+ },
157
+ },
158
+ ["source"],
159
+ ),
160
+ _tool(
161
+ "check_plugin",
162
+ "Validate a plugin directory against Agent Plugins 1.0.0 packaging "
163
+ "(plugin.json / skills / mcp.json). Acts as the spec's missing "
164
+ "official linter.",
165
+ {
166
+ "path": {
167
+ "type": "string",
168
+ "description": "Absolute path to the plugin root directory.",
169
+ },
170
+ },
171
+ ["path"],
172
+ ),
173
+ _tool(
174
+ "sandbox_run",
175
+ "Deterministic execution channel: run a command (no shell) in a "
176
+ "subprocess with a timeout and captured output. Turns 'tests pass' "
177
+ "into an observed fact. Use to verify test suites, type checks, "
178
+ "linters, or any claim that requires running code. "
179
+ "WARNING: this executes real processes on the user's machine — "
180
+ "MUST be gated behind user approval in the client. Commands may be "
181
+ "restricted by config 'sandbox_allowed_prefixes'.",
182
+ {
183
+ "command": {
184
+ "type": "array",
185
+ "items": {"type": "string"},
186
+ "description": "Command as an argument list, e.g. ['python3', '-m', 'pytest'].",
187
+ },
188
+ "timeout": {
189
+ "type": "integer",
190
+ "description": "Timeout in seconds (1-120, default 30).",
191
+ "default": 30,
192
+ },
193
+ "cwd": {
194
+ "type": "string",
195
+ "description": "Working directory (optional).",
196
+ },
197
+ },
198
+ ["command"],
199
+ ),
200
+ _tool(
201
+ "schema_validate",
202
+ "Validate structured output (JSON) against a JSON Schema. Uses the "
203
+ "jsonschema library (Draft 2020-12, full keyword coverage) when "
204
+ "installed; otherwise falls back to a built-in subset validator. "
205
+ "Results include which validator ran.",
206
+ {
207
+ "instance": {
208
+ "description": "The value to validate (any JSON value).",
209
+ },
210
+ "schema": {
211
+ "type": "object",
212
+ "description": "The JSON Schema to validate against.",
213
+ },
214
+ },
215
+ ["instance", "schema"],
216
+ ),
217
+ _tool(
218
+ "record_verification",
219
+ "Append one entry to the verification audit log "
220
+ "(verification-log.jsonl under PLUGIN_DATA). The SDD contract "
221
+ "requires a completion report with attached evidence — call this "
222
+ "after verify/scan/sandbox runs to persist what was checked, the "
223
+ "verdict, and a short summary. Returns the log path and total "
224
+ "entries.",
225
+ {
226
+ "task": {
227
+ "type": "string",
228
+ "description": "What was being verified, e.g. 'fix #42 login bug'.",
229
+ },
230
+ "checks": {
231
+ "type": "array",
232
+ "items": {
233
+ "type": "object",
234
+ "properties": {
235
+ "tool": {"type": "string"},
236
+ "status": {"type": "string", "enum": ["pass", "fail", "skipped"]},
237
+ "summary": {"type": "string"},
238
+ },
239
+ "required": ["tool", "status"],
240
+ },
241
+ "description": "Verification steps performed and their verdicts.",
242
+ },
243
+ "summary": {"type": "string", "description": "One-line overall conclusion."},
244
+ },
245
+ ["task"],
246
+ ),
247
+ ]
248
+
249
+
250
+ def _write_response(frame: dict) -> None:
251
+ """Single-writer-guarded stdout frame (safe from worker threads)."""
252
+ with _stdout_lock:
253
+ try:
254
+ sys.stdout.write(json.dumps(frame, ensure_ascii=False) + "\n")
255
+ sys.stdout.flush()
256
+ except BrokenPipeError:
257
+ pass # client disconnected; nothing to write to
258
+
259
+
260
+ def _execute(name: str, args: dict) -> dict:
261
+ """Run one tool call synchronously; returns the tool's result payload."""
262
+ if name == "verify_code":
263
+ return engine.detect_undefined_symbols(
264
+ args.get("source", ""),
265
+ args.get("language", "python"),
266
+ suppress=CONFIG_SUPPRESS,
267
+ )
268
+ if name == "check_contract":
269
+ return engine.check_contract(
270
+ args.get("source", ""),
271
+ args.get("contract", ""),
272
+ args.get("language", "python"),
273
+ )
274
+ if name == "check_imports":
275
+ return engine.check_imports(
276
+ args.get("source", ""),
277
+ args.get("language", "python"),
278
+ known_packages=CONFIG_KNOWN_PACKAGES,
279
+ )
280
+ if name == "scan_hallucination":
281
+ # explicit tool arguments win over config-file values
282
+ allowlist = args.get("allowlist")
283
+ if allowlist is None:
284
+ allowlist = CONFIG_ALLOWLIST
285
+ return engine.scan_hallucination_words(
286
+ args.get("source", ""),
287
+ allowlist,
288
+ CONFIG_SEVERITIES,
289
+ extra_tokens=CONFIG_EXTRA_TOKENS,
290
+ )
291
+ if name == "check_plugin":
292
+ return engine.check_plugin_conformance(args.get("path", ""))
293
+ if name == "schema_validate":
294
+ return engine.schema_validate(args.get("instance"), args.get("schema", {}))
295
+ if name == "record_verification":
296
+ return engine.record_verification(
297
+ args.get("task", ""),
298
+ args.get("checks") or [],
299
+ summary=args.get("summary"),
300
+ )
301
+ return {"isError": True, "content": [{"type": "text", "text": f"Unknown tool: {name}"}]}
302
+
303
+
304
+ def _run_call_async(msg_id, name: str, args: dict) -> threading.Thread:
305
+ """Run ANY tools/call request in a daemon thread so a long-running tool
306
+ never blocks the stdio read loop, keeping the session responsive and
307
+ (for sandbox_run) cancellable via notifications/cancelled."""
308
+ entry: dict = {"proc": None, "cancelled": False}
309
+ with _pending_lock:
310
+ _pending[msg_id] = entry
311
+
312
+ def finish(payload) -> None:
313
+ with _pending_lock:
314
+ was_cancelled = entry["cancelled"]
315
+ _pending.pop(msg_id, None)
316
+ if was_cancelled:
317
+ return # cancelled request gets no response (MCP cancellation)
318
+ _write_response({"jsonrpc": "2.0", "id": msg_id, "result": payload})
319
+
320
+ def work() -> None:
321
+ try:
322
+ if name == "sandbox_run":
323
+ # register the live process so notifications/cancelled can abort it
324
+ timeout = args.get("timeout")
325
+ result = engine.sandbox_run(
326
+ args.get("command", []),
327
+ int(timeout) if timeout is not None else CONFIG_TIMEOUT,
328
+ args.get("cwd"),
329
+ allowed_prefixes=CONFIG_SANDBOX_ALLOW,
330
+ on_proc=lambda proc: entry.__setitem__("proc", proc),
331
+ env_mode=CONFIG_SANDBOX_ENV,
332
+ )
333
+ else:
334
+ result = _execute(name, args)
335
+ except Exception as exc: # noqa: BLE001 - never kill the session
336
+ finish_error = {
337
+ "jsonrpc": "2.0",
338
+ "id": msg_id,
339
+ "error": {"code": -32603, "message": f"Internal error: {exc}"},
340
+ }
341
+ with _pending_lock:
342
+ was_cancelled = entry["cancelled"]
343
+ _pending.pop(msg_id, None)
344
+ if not was_cancelled:
345
+ _write_response(finish_error)
346
+ return
347
+ finish(
348
+ {
349
+ "content": [
350
+ {
351
+ "type": "text",
352
+ "text": json.dumps(result, ensure_ascii=False, indent=2),
353
+ }
354
+ ]
355
+ }
356
+ )
357
+
358
+ t = threading.Thread(target=work, daemon=True)
359
+ t.start()
360
+ return t
361
+
362
+
363
+ def _handle_cancellation(params: dict) -> None:
364
+ rid = params.get("requestId")
365
+ proc = None
366
+ with _pending_lock:
367
+ entry = _pending.get(rid)
368
+ if entry is not None:
369
+ entry["cancelled"] = True
370
+ proc = entry.get("proc")
371
+ if proc is not None:
372
+ engine.kill_tree(proc) # unblocks communicate() in the worker thread, tree-wide
373
+
374
+
375
+ def _dispatch(method: str, params: dict) -> dict:
376
+ if method == "tools/list":
377
+ return {"tools": TOOLS}
378
+ return {"isError": True, "content": [{"type": "text", "text": f"Unsupported method: {method}"}]}
379
+
380
+
381
+ def _error(msg_id, code: int, message: str) -> dict:
382
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
383
+
384
+
385
+ def _force_utf8_stdio() -> None:
386
+ """Force UTF-8 on stdin/stdout regardless of platform locale.
387
+
388
+ On Windows the default text encoding is the ANSI code page (e.g. cp936);
389
+ JSON-RPC traffic is UTF-8, and ``ensure_ascii=False`` responses can raise
390
+ UnicodeEncodeError mid-session. Reconfigure both directions explicitly.
391
+ """
392
+ for stream in (sys.stdin, sys.stdout):
393
+ reconfigure = getattr(stream, "reconfigure", None)
394
+ if callable(reconfigure):
395
+ try:
396
+ reconfigure(encoding="utf-8", errors="replace")
397
+ except (OSError, ValueError):
398
+ pass
399
+
400
+
401
+ # Protocol DoS bound: one JSON-RPC frame may not exceed this many characters
402
+ # (~2 MB). Larger lines are rejected unread with a -32600 error (id unknown).
403
+ MAX_FRAME_CHARS = 2_000_000
404
+
405
+
406
+ def main() -> None:
407
+ _force_utf8_stdio()
408
+ for raw in sys.stdin:
409
+ raw = raw.strip()
410
+ if not raw:
411
+ continue
412
+ if len(raw) > MAX_FRAME_CHARS:
413
+ _write_response(
414
+ _error(None, -32600, f"frame too large (> {MAX_FRAME_CHARS} chars); rejected")
415
+ )
416
+ continue
417
+ try:
418
+ msg = json.loads(raw)
419
+ except json.JSONDecodeError:
420
+ continue
421
+
422
+ msg_id = msg.get("id")
423
+ method = msg.get("method", "")
424
+ is_notification = "id" not in msg
425
+
426
+ try:
427
+ if method == "initialize":
428
+ requested = (msg.get("params") or {}).get("protocolVersion")
429
+ agreed = requested if requested in SUPPORTED_PROTOCOL else BASELINE_PROTOCOL
430
+ resp = {
431
+ "jsonrpc": "2.0",
432
+ "id": msg_id,
433
+ "result": {
434
+ "protocolVersion": agreed,
435
+ "capabilities": {"tools": {}},
436
+ "serverInfo": {"name": "agentseed", "version": VERSION},
437
+ },
438
+ }
439
+ elif method == "notifications/cancelled":
440
+ _handle_cancellation(msg.get("params") or {})
441
+ continue
442
+ elif method == "ping":
443
+ resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
444
+ elif method.startswith("tools/") and not is_notification:
445
+ params = msg.get("params", {}) or {}
446
+ if method == "tools/call":
447
+ # every tools/call runs in a worker thread: a long tool
448
+ # never blocks the read loop; sandbox stays cancellable
449
+ _run_call_async(
450
+ msg_id,
451
+ params.get("name", ""),
452
+ dict(params.get("arguments", {}) or {}),
453
+ )
454
+ continue
455
+ payload = _dispatch(method, params)
456
+ resp = {"jsonrpc": "2.0", "id": msg_id, "result": payload}
457
+ else:
458
+ # JSON-RPC 2.0 §5.1: unknown methods must be reported as the
459
+ # error -32601 (Method not found), not as a result.
460
+ resp = _error(msg_id, -32601, f"Method not found: {method}")
461
+ except Exception as exc: # noqa: BLE001 - never kill the session
462
+ resp = _error(msg_id, -32603, f"Internal error: {exc}")
463
+
464
+ # JSON-RPC 2.0 §4.1 gate: notifications MUST NOT receive a reply,
465
+ # whatever the method (covers ping/initialize sent as notifications).
466
+ if is_notification:
467
+ continue
468
+ _write_response(resp)
469
+
470
+
471
+ if __name__ == "__main__":
472
+ main()
@@ -0,0 +1,7 @@
1
+ # AgentSeed MCP server external dependencies.
2
+ # jsonschema — replaces the former hand-rolled JSON Schema subset validator.
3
+ # pyflakes — optional, used by symbols.py for more accurate Python AST analysis.
4
+ # pyyaml — optional, full YAML frontmatter parsing (lite fallback built in).
5
+ jsonschema>=4.0.0
6
+ pyflakes>=3.0.0
7
+ pyyaml>=6.0.0
@@ -0,0 +1,132 @@
1
+ """AgentSeed CLI tests (stdlib unittest, run the CLI as a subprocess)."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import unittest
7
+
8
+ HERE = os.path.dirname(os.path.abspath(__file__))
9
+ CLI = os.path.join(HERE, "guard_cli.py")
10
+ PLUGIN_ROOT = os.path.dirname(HERE)
11
+ PY = sys.executable
12
+
13
+
14
+ def run_cli(*args: str) -> subprocess.CompletedProcess:
15
+ return subprocess.run(
16
+ [PY, CLI, *args],
17
+ capture_output=True,
18
+ text=True,
19
+ encoding="utf-8",
20
+ errors="replace",
21
+ timeout=60,
22
+ cwd=PLUGIN_ROOT,
23
+ )
24
+
25
+
26
+ class TestCli(unittest.TestCase):
27
+ def test_verify_clean_exit_zero(self):
28
+ r = run_cli("verify", "import math\nprint(math.sqrt(4))\n")
29
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
30
+
31
+ def test_verify_hallucinated_exit_one(self):
32
+ r = run_cli("verify", "return magic_unknown()\n")
33
+ self.assertEqual(r.returncode, 1)
34
+ self.assertIn("magic_unknown", r.stdout)
35
+
36
+ def test_scan_warning_only_does_not_block(self):
37
+ # TODO alone is warning-severity by default -> exit 0
38
+ r = run_cli("scan", "# TODO: later\n")
39
+ self.assertEqual(r.returncode, 0)
40
+ self.assertIn('"blocking": false', r.stdout)
41
+
42
+ def test_scan_strict_blocks_stub(self):
43
+ r = run_cli("scan", "# TODO: later\n", "--strict")
44
+ self.assertEqual(r.returncode, 1)
45
+
46
+ def test_scan_oversold_blocks(self):
47
+ r = run_cli("scan", "all tests pass, guaranteed\n")
48
+ self.assertEqual(r.returncode, 1)
49
+
50
+ def test_check_self_ci_pass(self):
51
+ r = run_cli("check", PLUGIN_ROOT, "--ci")
52
+ self.assertEqual(r.returncode, 0, r.stdout)
53
+ self.assertIn('"ok": true', r.stdout)
54
+
55
+ def test_check_bad_plugin_fails(self):
56
+ import tempfile
57
+
58
+ with tempfile.TemporaryDirectory() as d:
59
+ r = run_cli("check", d, "--ci") # missing plugin.json
60
+ self.assertEqual(r.returncode, 1)
61
+
62
+ def test_sandbox_runs_and_exit_code(self):
63
+ r = run_cli("sandbox", "--", PY, "-c", "print(42)")
64
+ self.assertEqual(r.returncode, 0)
65
+ self.assertIn("42", r.stdout)
66
+
67
+ def test_sandbox_propagates_child_failure(self):
68
+ r = run_cli("sandbox", "--", PY, "-c", "raise SystemExit(3)")
69
+ self.assertEqual(r.returncode, 3)
70
+
71
+ def test_sandbox_missing_command_is_failure_not_pass(self):
72
+ r = run_cli("sandbox", "--", "definitely-not-a-real-cmd-xyz")
73
+ self.assertEqual(r.returncode, 1, r.stdout)
74
+
75
+ def test_verify_directory_gives_clean_error(self):
76
+ r = run_cli("verify", PLUGIN_ROOT) # a directory
77
+ self.assertEqual(r.returncode, 1)
78
+ self.assertIn("directory", r.stderr)
79
+
80
+ def test_scan_string_allowlist_does_not_suppress_all(self):
81
+ sys.path.insert(0, HERE)
82
+ from guard_engine import scan_hallucination_words # type: ignore
83
+
84
+ # "tx": per-character iteration (the old bug) would see 't' and
85
+ # suppress the hit; the coerced whole-string "tx" must not.
86
+ r = scan_hallucination_words("x = todo()", allowlist="tx")
87
+ self.assertEqual(len(r["hits"]), 1)
88
+
89
+ def test_baseline_create_then_new_signal_fails(self):
90
+ import tempfile
91
+
92
+ with tempfile.TemporaryDirectory() as d:
93
+ src = os.path.join(d, "mod.py")
94
+ base = os.path.join(d, "baseline.json")
95
+ with open(src, "w", encoding="utf-8") as fh:
96
+ fh.write("# fine\nx = 1\n")
97
+ r = run_cli("scan", d, "--baseline", base) # no baseline yet -> creates
98
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
99
+ self.assertTrue(os.path.isfile(base))
100
+ # same content: pass
101
+ r = run_cli("scan", d, "--baseline", base)
102
+ self.assertEqual(r.returncode, 0, r.stdout)
103
+ # NEW oversold signal appears -> exit 1 and names it
104
+ with open(src, "a", encoding="utf-8") as fh:
105
+ fh.write("\n# all tests pass, guaranteed\n")
106
+ r = run_cli("scan", d, "--baseline", base)
107
+ self.assertEqual(r.returncode, 1, r.stdout)
108
+ self.assertIn("oversold|all tests pass", r.stdout)
109
+ # --update-baseline freezes the new state
110
+ r = run_cli("scan", d, "--baseline", base, "--update-baseline")
111
+ self.assertEqual(r.returncode, 0, r.stdout)
112
+ r = run_cli("scan", d, "--baseline", base)
113
+ self.assertEqual(r.returncode, 0, r.stdout)
114
+
115
+ def test_baseline_line_edits_do_not_churn(self):
116
+ import tempfile
117
+
118
+ with tempfile.TemporaryDirectory() as d:
119
+ src = os.path.join(d, "doc.md")
120
+ base = os.path.join(d, "baseline.json")
121
+ with open(src, "w", encoding="utf-8") as fh:
122
+ fh.write("production ready claim lives here\n")
123
+ run_cli("scan", d, "--baseline", base)
124
+ # moving the SAME hit to another line must stay green (line-free fp)
125
+ with open(src, "w", encoding="utf-8") as fh:
126
+ fh.write("\n\n\nproduction ready claim lives here\n")
127
+ r = run_cli("scan", d, "--baseline", base)
128
+ self.assertEqual(r.returncode, 0, r.stdout)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()