design-playbook 0.10.0 → 0.11.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/LICENSE +28 -28
- package/NOTICE +37 -37
- package/commands/ui-review.md +8 -8
- package/commands/ux-spec.md +8 -8
- package/mcp/_transport.py +242 -242
- package/mcp/evidence/README.md +40 -40
- package/mcp/preview/browser.py +44 -9
- package/mcp/preview/control.js +118 -1
- package/mcp/preview/control.py +8 -2
- package/mcp/preview/server.py +129 -129
- package/mcp/preview/test_anchor_v2.py +69 -0
- package/mcp/preview/test_server_stdio.py +10 -1
- package/mcp/preview/test_transaction.py +674 -436
- package/mcp/preview/test_versions.py +572 -0
- package/mcp/preview/transaction.py +864 -613
- package/mcp/preview/versions.py +396 -0
- package/mcp/test_transport.py +39 -39
- package/package.json +1 -1
- package/skills/craft-guard/SKILL.md +59 -59
- package/skills/craft-guard/references/craft.md +29 -29
- package/skills/craft-guard/references/detectors.md +124 -124
- package/skills/design-baseline/SKILL.md +134 -134
- package/skills/design-baseline/references/design-template.md +73 -73
- package/skills/design-baseline/references/extraction-guidance.md +39 -39
- package/skills/design-baseline/scripts/design_baseline.py +780 -780
- package/skills/native-craft/SKILL.md +59 -59
- package/skills/native-craft/references/native-feel.md +79 -79
- package/skills/ui-evaluator/references/rubric.md +45 -45
- package/skills/ui-picker/references/components.md +31 -31
- package/skills/ui-picker/references/design.md +21 -21
- package/skills/ui-picker/references/domain.md +26 -26
- package/skills/ui-picker/references/template.md +24 -24
package/LICENSE
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Bandersnatch0x and contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
This LICENSE covers only the authored materials in this package (skills,
|
|
26
|
-
commands, plugin metadata, and self-written examples). It does not grant
|
|
27
|
-
rights to any third-party playbook manuscript, figures, or trademarks that may
|
|
28
|
-
exist elsewhere in a parent monorepo for learning purposes.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bandersnatch0x and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This LICENSE covers only the authored materials in this package (skills,
|
|
26
|
+
commands, plugin metadata, and self-written examples). It does not grant
|
|
27
|
+
rights to any third-party playbook manuscript, figures, or trademarks that may
|
|
28
|
+
exist elsewhere in a parent monorepo for learning purposes.
|
package/NOTICE
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
# NOTICE
|
|
2
|
-
|
|
3
|
-
design-playbook is an original agent plugin (skills, commands, plugin metadata,
|
|
4
|
-
workflow docs, and self-authored examples), licensed under MIT (see `LICENSE`).
|
|
5
|
-
|
|
6
|
-
It is **not** a port, overlay, or redistribution of any third-party design
|
|
7
|
-
playbook's manuscript, figures, brand marks, or demo site. No rights to such
|
|
8
|
-
materials are claimed or granted here.
|
|
9
|
-
|
|
10
|
-
The `native-craft` skill is a derivative of yetone/native-feel-skill (MIT). We
|
|
11
|
-
authored our own Design I/O leaf and condensed its decision gate and native
|
|
12
|
-
conventions audit in our own voice; the full depth (WebView survival, IPC
|
|
13
|
-
contract, memory truths, Raycast evidence) remains in the original skill, which
|
|
14
|
-
users may install separately. The MIT License of the original is reproduced
|
|
15
|
-
below in full, as required for derivative works:
|
|
16
|
-
|
|
17
|
-
The MIT License (MIT)
|
|
18
|
-
|
|
19
|
-
Copyright (c) 2026 yetone
|
|
20
|
-
|
|
21
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
22
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
23
|
-
in the Software without restriction, including without limitation the rights
|
|
24
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
25
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
26
|
-
furnished to do so, subject to the following conditions:
|
|
27
|
-
|
|
28
|
-
The above copyright notice and this permission notice shall be included in all
|
|
29
|
-
copies or substantial portions of the Software.
|
|
30
|
-
|
|
31
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
-
SOFTWARE.
|
|
1
|
+
# NOTICE
|
|
2
|
+
|
|
3
|
+
design-playbook is an original agent plugin (skills, commands, plugin metadata,
|
|
4
|
+
workflow docs, and self-authored examples), licensed under MIT (see `LICENSE`).
|
|
5
|
+
|
|
6
|
+
It is **not** a port, overlay, or redistribution of any third-party design
|
|
7
|
+
playbook's manuscript, figures, brand marks, or demo site. No rights to such
|
|
8
|
+
materials are claimed or granted here.
|
|
9
|
+
|
|
10
|
+
The `native-craft` skill is a derivative of yetone/native-feel-skill (MIT). We
|
|
11
|
+
authored our own Design I/O leaf and condensed its decision gate and native
|
|
12
|
+
conventions audit in our own voice; the full depth (WebView survival, IPC
|
|
13
|
+
contract, memory truths, Raycast evidence) remains in the original skill, which
|
|
14
|
+
users may install separately. The MIT License of the original is reproduced
|
|
15
|
+
below in full, as required for derivative works:
|
|
16
|
+
|
|
17
|
+
The MIT License (MIT)
|
|
18
|
+
|
|
19
|
+
Copyright (c) 2026 yetone
|
|
20
|
+
|
|
21
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
22
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
23
|
+
in the Software without restriction, including without limitation the rights
|
|
24
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
25
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
26
|
+
furnished to do so, subject to the following conditions:
|
|
27
|
+
|
|
28
|
+
The above copyright notice and this permission notice shall be included in all
|
|
29
|
+
copies or substantial portions of the Software.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
package/commands/ui-review.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Declaration-backed UI review with point-back findings
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Run skill **ui-evaluator** (pull craft-guard checks when AI slop/motion/loading is in scope). Output issue/source/fix/severity; blocking first.
|
|
6
|
-
|
|
7
|
-
Scope:
|
|
8
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Declaration-backed UI review with point-back findings
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Run skill **ui-evaluator** (pull craft-guard checks when AI slop/motion/loading is in scope). Output issue/source/fix/severity; blocking first.
|
|
6
|
+
|
|
7
|
+
Scope:
|
|
8
|
+
$ARGUMENTS
|
package/commands/ux-spec.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Six-layer spec.md only (stop before UI shell/code)
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Run skill **ux-spec** only. Emit complete `spec.md`. Do not pick templates or write UI.
|
|
6
|
-
|
|
7
|
-
Request:
|
|
8
|
-
$ARGUMENTS
|
|
1
|
+
---
|
|
2
|
+
description: Six-layer spec.md only (stop before UI shell/code)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Run skill **ux-spec** only. Emit complete `spec.md`. Do not pick templates or write UI.
|
|
6
|
+
|
|
7
|
+
Request:
|
|
8
|
+
$ARGUMENTS
|
package/mcp/_transport.py
CHANGED
|
@@ -1,242 +1,242 @@
|
|
|
1
|
-
"""Shared stdio JSON-RPC framing + single-tool dispatch for the bundled MCP servers.
|
|
2
|
-
|
|
3
|
-
Both the preview and evidence adapters speak the same wire format
|
|
4
|
-
(Content-Length- or newline-delimited JSON-RPC over stdio) and run the same
|
|
5
|
-
JSON-RPC dispatch (initialize / tools/list / tools/call / ping /
|
|
6
|
-
method-not-found). This module owns both once so the two servers keep them in
|
|
7
|
-
lockstep (ADR-0009 bundled layout). Each server runs in its own process, so
|
|
8
|
-
the module-level framing state is per-process and never shared across servers.
|
|
9
|
-
|
|
10
|
-
The one policy that is deliberately per-server is malformed-input recovery,
|
|
11
|
-
expressed as the ``recover_from_malformed`` flag on :func:`serve_stdio`:
|
|
12
|
-
``read_message`` always raises on a bad frame; preview re-raises it
|
|
13
|
-
(fail-fast — the server ends), while evidence catches it, replies ``-32700``
|
|
14
|
-
/ ``-32600``, and keeps serving (fail-soft — one bad client frame cannot abort
|
|
15
|
-
a capture run).
|
|
16
|
-
"""
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import json
|
|
20
|
-
import sys
|
|
21
|
-
from typing import Any, Callable
|
|
22
|
-
|
|
23
|
-
STDIO_FRAMING_CONTENT_LENGTH = "content-length"
|
|
24
|
-
STDIO_FRAMING_NEWLINE = "newline"
|
|
25
|
-
_stdio_framing: str | None = None
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class ToolError(Exception):
|
|
29
|
-
"""Recoverable domain error with MCP structured error content."""
|
|
30
|
-
|
|
31
|
-
def __init__(self, message: str, structured_content: dict[str, Any]):
|
|
32
|
-
super().__init__(message)
|
|
33
|
-
self.structured_content = structured_content
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def read_message() -> dict[str, Any] | None:
|
|
37
|
-
"""Read one Content-Length- or newline-delimited JSON-RPC message.
|
|
38
|
-
|
|
39
|
-
Returns None at EOF. Raises json/unicode/value/EOF errors on a bad
|
|
40
|
-
frame; the caller decides the recovery policy (see module docstring).
|
|
41
|
-
"""
|
|
42
|
-
global _stdio_framing
|
|
43
|
-
|
|
44
|
-
while True:
|
|
45
|
-
first_line = sys.stdin.buffer.readline()
|
|
46
|
-
if not first_line:
|
|
47
|
-
return None
|
|
48
|
-
if first_line not in (b"\r\n", b"\n"):
|
|
49
|
-
break
|
|
50
|
-
|
|
51
|
-
if not first_line.lower().startswith(b"content-length:"):
|
|
52
|
-
_stdio_framing = STDIO_FRAMING_NEWLINE
|
|
53
|
-
return json.loads(first_line.decode("utf-8"))
|
|
54
|
-
|
|
55
|
-
_stdio_framing = STDIO_FRAMING_CONTENT_LENGTH
|
|
56
|
-
headers: dict[str, str] = {}
|
|
57
|
-
line = first_line
|
|
58
|
-
while line not in (b"\r\n", b"\n"):
|
|
59
|
-
key, separator, value = line.decode("utf-8").partition(":")
|
|
60
|
-
if not separator:
|
|
61
|
-
raise ValueError(f"invalid MCP stdio header: {line!r}")
|
|
62
|
-
headers[key.strip().lower()] = value.strip()
|
|
63
|
-
line = sys.stdin.buffer.readline()
|
|
64
|
-
if not line:
|
|
65
|
-
raise EOFError("MCP stdio headers ended before the blank line")
|
|
66
|
-
length = int(headers.get("content-length", "0"))
|
|
67
|
-
if length <= 0:
|
|
68
|
-
raise ValueError("MCP stdio Content-Length must be positive")
|
|
69
|
-
body = sys.stdin.buffer.read(length)
|
|
70
|
-
if len(body) != length:
|
|
71
|
-
raise EOFError(
|
|
72
|
-
f"MCP stdio body ended early: expected {length}, got {len(body)}"
|
|
73
|
-
)
|
|
74
|
-
return json.loads(body.decode("utf-8"))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def write_message(payload: dict[str, Any]) -> None:
|
|
78
|
-
"""Write one JSON-RPC message in the framing detected by read_message."""
|
|
79
|
-
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
80
|
-
if _stdio_framing == STDIO_FRAMING_NEWLINE:
|
|
81
|
-
sys.stdout.buffer.write(raw + b"\n")
|
|
82
|
-
else:
|
|
83
|
-
sys.stdout.buffer.write(
|
|
84
|
-
f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii") + raw
|
|
85
|
-
)
|
|
86
|
-
sys.stdout.buffer.flush()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _result_text(payload: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
-
return {
|
|
91
|
-
"content": [
|
|
92
|
-
{
|
|
93
|
-
"type": "text",
|
|
94
|
-
"text": json.dumps(payload, ensure_ascii=False, indent=2),
|
|
95
|
-
}
|
|
96
|
-
],
|
|
97
|
-
"structuredContent": payload,
|
|
98
|
-
"isError": False,
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _error_result(
|
|
103
|
-
message: str, structured_content: dict[str, Any] | None = None
|
|
104
|
-
) -> dict[str, Any]:
|
|
105
|
-
result: dict[str, Any] = {
|
|
106
|
-
"content": [{"type": "text", "text": message}],
|
|
107
|
-
"isError": True,
|
|
108
|
-
}
|
|
109
|
-
if structured_content is not None:
|
|
110
|
-
result["structuredContent"] = structured_content
|
|
111
|
-
return result
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _exception_result(exc: Exception) -> dict[str, Any]:
|
|
115
|
-
structured = exc.structured_content if isinstance(exc, ToolError) else None
|
|
116
|
-
return _error_result(str(exc), structured)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def serve_stdio(
|
|
120
|
-
server_name: str,
|
|
121
|
-
server_version: str,
|
|
122
|
-
tool_schema: dict[str, Any],
|
|
123
|
-
handle_tool: Callable[[dict[str, Any]], dict[str, Any]],
|
|
124
|
-
*,
|
|
125
|
-
recover_from_malformed: bool) -> None:
|
|
126
|
-
"""Run the shared single-tool MCP stdio dispatch loop (ADR-0009).
|
|
127
|
-
|
|
128
|
-
Both bundled adapters speak the same JSON-RPC protocol; owning the
|
|
129
|
-
dispatch once here keeps initialize / tools/list / tools/call / ping /
|
|
130
|
-
method-not-found in lockstep instead of copy-pasted in each server.
|
|
131
|
-
|
|
132
|
-
``tool_schema`` is the single advertised tool (its ``name`` is the
|
|
133
|
-
accepted ``tools/call`` name); ``handle_tool`` maps the call's
|
|
134
|
-
``arguments`` to the payload returned to the client. A raised exception
|
|
135
|
-
becomes a tool-level error result (``isError: true``); a returned dict
|
|
136
|
-
is the structured success payload.
|
|
137
|
-
|
|
138
|
-
The one deliberately per-server policy is malformed-input recovery (see
|
|
139
|
-
module docstring):
|
|
140
|
-
|
|
141
|
-
* ``recover_from_malformed=False`` (preview): a bad frame or a
|
|
142
|
-
non-object request propagates and ends the server (fail-fast).
|
|
143
|
-
* ``recover_from_malformed=True`` (evidence): reply ``-32700`` (bad
|
|
144
|
-
frame) or ``-32600`` (non-object request) and keep serving, so one
|
|
145
|
-
bad client frame cannot abort a capture run.
|
|
146
|
-
"""
|
|
147
|
-
print(f"{server_name} MCP server starting (stdio)",
|
|
148
|
-
file=sys.stderr, flush=True)
|
|
149
|
-
while True:
|
|
150
|
-
try:
|
|
151
|
-
msg = read_message()
|
|
152
|
-
except (json.JSONDecodeError, UnicodeDecodeError,
|
|
153
|
-
ValueError, EOFError) as exc:
|
|
154
|
-
if not recover_from_malformed:
|
|
155
|
-
raise
|
|
156
|
-
print(f"MCP parse error: {exc}", file=sys.stderr, flush=True)
|
|
157
|
-
write_message({
|
|
158
|
-
"jsonrpc": "2.0",
|
|
159
|
-
"id": None,
|
|
160
|
-
"error": {"code": -32700, "message": f"Parse error: {exc}"},
|
|
161
|
-
})
|
|
162
|
-
continue
|
|
163
|
-
if msg is None:
|
|
164
|
-
break
|
|
165
|
-
if recover_from_malformed and not isinstance(msg, dict):
|
|
166
|
-
write_message({
|
|
167
|
-
"jsonrpc": "2.0",
|
|
168
|
-
"id": None,
|
|
169
|
-
"error": {"code": -32600, "message": "Invalid Request"},
|
|
170
|
-
})
|
|
171
|
-
continue
|
|
172
|
-
|
|
173
|
-
method = msg.get("method")
|
|
174
|
-
msg_id = msg.get("id")
|
|
175
|
-
params = msg.get("params") or {}
|
|
176
|
-
|
|
177
|
-
if method == "initialize":
|
|
178
|
-
write_message({
|
|
179
|
-
"jsonrpc": "2.0",
|
|
180
|
-
"id": msg_id,
|
|
181
|
-
"result": {
|
|
182
|
-
"protocolVersion": params.get(
|
|
183
|
-
"protocolVersion", "2024-11-05"),
|
|
184
|
-
"capabilities": {"tools": {}},
|
|
185
|
-
"serverInfo": {
|
|
186
|
-
"name": server_name,
|
|
187
|
-
"version": server_version,
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
})
|
|
191
|
-
continue
|
|
192
|
-
|
|
193
|
-
if method == "notifications/initialized":
|
|
194
|
-
continue
|
|
195
|
-
|
|
196
|
-
if method == "tools/list":
|
|
197
|
-
write_message({
|
|
198
|
-
"jsonrpc": "2.0",
|
|
199
|
-
"id": msg_id,
|
|
200
|
-
"result": {"tools": [tool_schema]},
|
|
201
|
-
})
|
|
202
|
-
continue
|
|
203
|
-
|
|
204
|
-
if method == "tools/call":
|
|
205
|
-
name = params.get("name")
|
|
206
|
-
arguments = params.get("arguments") or {}
|
|
207
|
-
if name != tool_schema["name"]:
|
|
208
|
-
write_message({
|
|
209
|
-
"jsonrpc": "2.0",
|
|
210
|
-
"id": msg_id,
|
|
211
|
-
"result": _error_result(f"unknown tool: {name}"),
|
|
212
|
-
})
|
|
213
|
-
continue
|
|
214
|
-
try:
|
|
215
|
-
payload = handle_tool(arguments)
|
|
216
|
-
write_message({
|
|
217
|
-
"jsonrpc": "2.0",
|
|
218
|
-
"id": msg_id,
|
|
219
|
-
"result": _result_text(payload),
|
|
220
|
-
})
|
|
221
|
-
except Exception as exc: # noqa: BLE001 — return to client
|
|
222
|
-
print(f"tools/call error: {exc}", file=sys.stderr, flush=True)
|
|
223
|
-
write_message({
|
|
224
|
-
"jsonrpc": "2.0",
|
|
225
|
-
"id": msg_id,
|
|
226
|
-
"result": _exception_result(exc),
|
|
227
|
-
})
|
|
228
|
-
continue
|
|
229
|
-
|
|
230
|
-
if method == "ping":
|
|
231
|
-
write_message({"jsonrpc": "2.0", "id": msg_id, "result": {}})
|
|
232
|
-
continue
|
|
233
|
-
|
|
234
|
-
if msg_id is not None:
|
|
235
|
-
write_message({
|
|
236
|
-
"jsonrpc": "2.0",
|
|
237
|
-
"id": msg_id,
|
|
238
|
-
"error": {
|
|
239
|
-
"code": -32601,
|
|
240
|
-
"message": f"Method not found: {method}",
|
|
241
|
-
},
|
|
242
|
-
})
|
|
1
|
+
"""Shared stdio JSON-RPC framing + single-tool dispatch for the bundled MCP servers.
|
|
2
|
+
|
|
3
|
+
Both the preview and evidence adapters speak the same wire format
|
|
4
|
+
(Content-Length- or newline-delimited JSON-RPC over stdio) and run the same
|
|
5
|
+
JSON-RPC dispatch (initialize / tools/list / tools/call / ping /
|
|
6
|
+
method-not-found). This module owns both once so the two servers keep them in
|
|
7
|
+
lockstep (ADR-0009 bundled layout). Each server runs in its own process, so
|
|
8
|
+
the module-level framing state is per-process and never shared across servers.
|
|
9
|
+
|
|
10
|
+
The one policy that is deliberately per-server is malformed-input recovery,
|
|
11
|
+
expressed as the ``recover_from_malformed`` flag on :func:`serve_stdio`:
|
|
12
|
+
``read_message`` always raises on a bad frame; preview re-raises it
|
|
13
|
+
(fail-fast — the server ends), while evidence catches it, replies ``-32700``
|
|
14
|
+
/ ``-32600``, and keeps serving (fail-soft — one bad client frame cannot abort
|
|
15
|
+
a capture run).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
STDIO_FRAMING_CONTENT_LENGTH = "content-length"
|
|
24
|
+
STDIO_FRAMING_NEWLINE = "newline"
|
|
25
|
+
_stdio_framing: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolError(Exception):
|
|
29
|
+
"""Recoverable domain error with MCP structured error content."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, structured_content: dict[str, Any]):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.structured_content = structured_content
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_message() -> dict[str, Any] | None:
|
|
37
|
+
"""Read one Content-Length- or newline-delimited JSON-RPC message.
|
|
38
|
+
|
|
39
|
+
Returns None at EOF. Raises json/unicode/value/EOF errors on a bad
|
|
40
|
+
frame; the caller decides the recovery policy (see module docstring).
|
|
41
|
+
"""
|
|
42
|
+
global _stdio_framing
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
first_line = sys.stdin.buffer.readline()
|
|
46
|
+
if not first_line:
|
|
47
|
+
return None
|
|
48
|
+
if first_line not in (b"\r\n", b"\n"):
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if not first_line.lower().startswith(b"content-length:"):
|
|
52
|
+
_stdio_framing = STDIO_FRAMING_NEWLINE
|
|
53
|
+
return json.loads(first_line.decode("utf-8"))
|
|
54
|
+
|
|
55
|
+
_stdio_framing = STDIO_FRAMING_CONTENT_LENGTH
|
|
56
|
+
headers: dict[str, str] = {}
|
|
57
|
+
line = first_line
|
|
58
|
+
while line not in (b"\r\n", b"\n"):
|
|
59
|
+
key, separator, value = line.decode("utf-8").partition(":")
|
|
60
|
+
if not separator:
|
|
61
|
+
raise ValueError(f"invalid MCP stdio header: {line!r}")
|
|
62
|
+
headers[key.strip().lower()] = value.strip()
|
|
63
|
+
line = sys.stdin.buffer.readline()
|
|
64
|
+
if not line:
|
|
65
|
+
raise EOFError("MCP stdio headers ended before the blank line")
|
|
66
|
+
length = int(headers.get("content-length", "0"))
|
|
67
|
+
if length <= 0:
|
|
68
|
+
raise ValueError("MCP stdio Content-Length must be positive")
|
|
69
|
+
body = sys.stdin.buffer.read(length)
|
|
70
|
+
if len(body) != length:
|
|
71
|
+
raise EOFError(
|
|
72
|
+
f"MCP stdio body ended early: expected {length}, got {len(body)}"
|
|
73
|
+
)
|
|
74
|
+
return json.loads(body.decode("utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_message(payload: dict[str, Any]) -> None:
|
|
78
|
+
"""Write one JSON-RPC message in the framing detected by read_message."""
|
|
79
|
+
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
80
|
+
if _stdio_framing == STDIO_FRAMING_NEWLINE:
|
|
81
|
+
sys.stdout.buffer.write(raw + b"\n")
|
|
82
|
+
else:
|
|
83
|
+
sys.stdout.buffer.write(
|
|
84
|
+
f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii") + raw
|
|
85
|
+
)
|
|
86
|
+
sys.stdout.buffer.flush()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _result_text(payload: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"content": [
|
|
92
|
+
{
|
|
93
|
+
"type": "text",
|
|
94
|
+
"text": json.dumps(payload, ensure_ascii=False, indent=2),
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"structuredContent": payload,
|
|
98
|
+
"isError": False,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _error_result(
|
|
103
|
+
message: str, structured_content: dict[str, Any] | None = None
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
result: dict[str, Any] = {
|
|
106
|
+
"content": [{"type": "text", "text": message}],
|
|
107
|
+
"isError": True,
|
|
108
|
+
}
|
|
109
|
+
if structured_content is not None:
|
|
110
|
+
result["structuredContent"] = structured_content
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _exception_result(exc: Exception) -> dict[str, Any]:
|
|
115
|
+
structured = exc.structured_content if isinstance(exc, ToolError) else None
|
|
116
|
+
return _error_result(str(exc), structured)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def serve_stdio(
|
|
120
|
+
server_name: str,
|
|
121
|
+
server_version: str,
|
|
122
|
+
tool_schema: dict[str, Any],
|
|
123
|
+
handle_tool: Callable[[dict[str, Any]], dict[str, Any]],
|
|
124
|
+
*,
|
|
125
|
+
recover_from_malformed: bool) -> None:
|
|
126
|
+
"""Run the shared single-tool MCP stdio dispatch loop (ADR-0009).
|
|
127
|
+
|
|
128
|
+
Both bundled adapters speak the same JSON-RPC protocol; owning the
|
|
129
|
+
dispatch once here keeps initialize / tools/list / tools/call / ping /
|
|
130
|
+
method-not-found in lockstep instead of copy-pasted in each server.
|
|
131
|
+
|
|
132
|
+
``tool_schema`` is the single advertised tool (its ``name`` is the
|
|
133
|
+
accepted ``tools/call`` name); ``handle_tool`` maps the call's
|
|
134
|
+
``arguments`` to the payload returned to the client. A raised exception
|
|
135
|
+
becomes a tool-level error result (``isError: true``); a returned dict
|
|
136
|
+
is the structured success payload.
|
|
137
|
+
|
|
138
|
+
The one deliberately per-server policy is malformed-input recovery (see
|
|
139
|
+
module docstring):
|
|
140
|
+
|
|
141
|
+
* ``recover_from_malformed=False`` (preview): a bad frame or a
|
|
142
|
+
non-object request propagates and ends the server (fail-fast).
|
|
143
|
+
* ``recover_from_malformed=True`` (evidence): reply ``-32700`` (bad
|
|
144
|
+
frame) or ``-32600`` (non-object request) and keep serving, so one
|
|
145
|
+
bad client frame cannot abort a capture run.
|
|
146
|
+
"""
|
|
147
|
+
print(f"{server_name} MCP server starting (stdio)",
|
|
148
|
+
file=sys.stderr, flush=True)
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
msg = read_message()
|
|
152
|
+
except (json.JSONDecodeError, UnicodeDecodeError,
|
|
153
|
+
ValueError, EOFError) as exc:
|
|
154
|
+
if not recover_from_malformed:
|
|
155
|
+
raise
|
|
156
|
+
print(f"MCP parse error: {exc}", file=sys.stderr, flush=True)
|
|
157
|
+
write_message({
|
|
158
|
+
"jsonrpc": "2.0",
|
|
159
|
+
"id": None,
|
|
160
|
+
"error": {"code": -32700, "message": f"Parse error: {exc}"},
|
|
161
|
+
})
|
|
162
|
+
continue
|
|
163
|
+
if msg is None:
|
|
164
|
+
break
|
|
165
|
+
if recover_from_malformed and not isinstance(msg, dict):
|
|
166
|
+
write_message({
|
|
167
|
+
"jsonrpc": "2.0",
|
|
168
|
+
"id": None,
|
|
169
|
+
"error": {"code": -32600, "message": "Invalid Request"},
|
|
170
|
+
})
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
method = msg.get("method")
|
|
174
|
+
msg_id = msg.get("id")
|
|
175
|
+
params = msg.get("params") or {}
|
|
176
|
+
|
|
177
|
+
if method == "initialize":
|
|
178
|
+
write_message({
|
|
179
|
+
"jsonrpc": "2.0",
|
|
180
|
+
"id": msg_id,
|
|
181
|
+
"result": {
|
|
182
|
+
"protocolVersion": params.get(
|
|
183
|
+
"protocolVersion", "2024-11-05"),
|
|
184
|
+
"capabilities": {"tools": {}},
|
|
185
|
+
"serverInfo": {
|
|
186
|
+
"name": server_name,
|
|
187
|
+
"version": server_version,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if method == "notifications/initialized":
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if method == "tools/list":
|
|
197
|
+
write_message({
|
|
198
|
+
"jsonrpc": "2.0",
|
|
199
|
+
"id": msg_id,
|
|
200
|
+
"result": {"tools": [tool_schema]},
|
|
201
|
+
})
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if method == "tools/call":
|
|
205
|
+
name = params.get("name")
|
|
206
|
+
arguments = params.get("arguments") or {}
|
|
207
|
+
if name != tool_schema["name"]:
|
|
208
|
+
write_message({
|
|
209
|
+
"jsonrpc": "2.0",
|
|
210
|
+
"id": msg_id,
|
|
211
|
+
"result": _error_result(f"unknown tool: {name}"),
|
|
212
|
+
})
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
payload = handle_tool(arguments)
|
|
216
|
+
write_message({
|
|
217
|
+
"jsonrpc": "2.0",
|
|
218
|
+
"id": msg_id,
|
|
219
|
+
"result": _result_text(payload),
|
|
220
|
+
})
|
|
221
|
+
except Exception as exc: # noqa: BLE001 — return to client
|
|
222
|
+
print(f"tools/call error: {exc}", file=sys.stderr, flush=True)
|
|
223
|
+
write_message({
|
|
224
|
+
"jsonrpc": "2.0",
|
|
225
|
+
"id": msg_id,
|
|
226
|
+
"result": _exception_result(exc),
|
|
227
|
+
})
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if method == "ping":
|
|
231
|
+
write_message({"jsonrpc": "2.0", "id": msg_id, "result": {}})
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
if msg_id is not None:
|
|
235
|
+
write_message({
|
|
236
|
+
"jsonrpc": "2.0",
|
|
237
|
+
"id": msg_id,
|
|
238
|
+
"error": {
|
|
239
|
+
"code": -32601,
|
|
240
|
+
"message": f"Method not found: {method}",
|
|
241
|
+
},
|
|
242
|
+
})
|