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.
- package/CHANGELOG.md +178 -0
- package/LICENSE +202 -0
- package/README.ja.md +320 -0
- package/README.md +318 -0
- package/README.zh.md +306 -0
- package/bin/cli.js +37 -0
- package/mcp.json +12 -0
- package/package.json +30 -0
- package/plugin.json +22 -0
- package/server/.agentseed/verification-log.jsonl +2 -0
- package/server/__pycache__/guard_cli.cpython-313.pyc +0 -0
- package/server/__pycache__/guard_engine.cpython-313.pyc +0 -0
- package/server/__pycache__/test_cli.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_cli.cpython-313.pyc +0 -0
- package/server/__pycache__/test_features.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_features.cpython-313.pyc +0 -0
- package/server/__pycache__/test_guard.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_guard.cpython-313.pyc +0 -0
- package/server/__pycache__/test_hook.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_hook.cpython-313.pyc +0 -0
- package/server/__pycache__/test_manifests.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_manifests.cpython-313.pyc +0 -0
- package/server/__pycache__/test_server.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_server.cpython-313.pyc +0 -0
- package/server/engine/__init__.py +64 -0
- package/server/engine/__pycache__/__init__.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/audit.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/config.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/hallucination.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/imports.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/plugin.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/schema.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/symbols.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/version.cpython-313.pyc +0 -0
- package/server/engine/audit.py +84 -0
- package/server/engine/config.py +131 -0
- package/server/engine/hallucination.py +254 -0
- package/server/engine/imports.py +136 -0
- package/server/engine/plugin.py +367 -0
- package/server/engine/sandbox.py +287 -0
- package/server/engine/schema.py +193 -0
- package/server/engine/symbols.py +984 -0
- package/server/engine/version.py +17 -0
- package/server/guard_cli.py +455 -0
- package/server/guard_engine.py +111 -0
- package/server/guard_hook.py +404 -0
- package/server/guard_server.py +472 -0
- package/server/requirements.txt +7 -0
- package/server/test_cli.py +132 -0
- package/server/test_features.py +426 -0
- package/server/test_guard.py +828 -0
- package/server/test_hook.py +331 -0
- package/server/test_manifests.py +70 -0
- package/server/test_server.py +247 -0
- package/skills/verify-before-code/SKILL.ja.md +116 -0
- package/skills/verify-before-code/SKILL.md +140 -0
- package/skills/verify-before-code/SKILL.zh.md +117 -0
- package/skills/verify-before-code/references/DEFAULT-NORMS.md +52 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.ja.md +121 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.md +166 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.zh.md +145 -0
- package/skills/verify-before-code/references/PROMPT-POOL.ja.md +248 -0
- package/skills/verify-before-code/references/PROMPT-POOL.md +282 -0
- package/skills/verify-before-code/references/PROMPT-POOL.zh.md +252 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.ja.md +61 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.md +66 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.zh.md +58 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.ja.md +62 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.md +62 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.zh.md +54 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.ja.md +68 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.md +73 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.zh.md +68 -0
- package/skills/verify-before-code/scripts/check.ps1 +52 -0
- 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()
|