Skip to content

Commit 1da4f4e

Browse files
authored
feat(debugmcp): declare outputSchema for every tool (#8)
Every tool already reports success as structuredContent, but none declared an outputSchema. A client therefore had no contract for the structured object it receives — it could only guess the shape or fall back to parsing the text block. A tool that emits structured output should describe it. - declare outputSchema on all 11 tools; the control tools share one schema, since a run either stops or finishes - require only the key present in every success (event, id) and declare the union of the rest, so the tools with two success shapes stay honest rather than promising keys they sometimes omit - emit an empty object rather than null for the no-argument inputSchema properties, which was not valid JSON Schema The declarations are held to the handlers by tests: one asserts every tool declares both schemas with required a subset of properties, the other drives a real debug session and validates each emitted payload against its declared schema, covering both success shapes. Tags: #lua-pure #debugmcp #mcp #schema #contract
1 parent af70d8a commit 1da4f4e

2 files changed

Lines changed: 289 additions & 14 deletions

File tree

‎debugmcp/schema_test.go‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package debugmcp
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
// toolDef looks a tool up in the tools/list payload.
9+
func toolDef(t *testing.T, name string) map[string]any {
10+
t.Helper()
11+
for _, d := range toolDefs() {
12+
if d["name"] == name {
13+
return d
14+
}
15+
}
16+
t.Fatalf("no tool definition for %q", name)
17+
return nil
18+
}
19+
20+
// schemaOf returns a tool's named schema as a decoded JSON object — decoded
21+
// rather than read directly so the test sees what a client sees.
22+
func schemaOf(t *testing.T, tool map[string]any, key string) map[string]any {
23+
t.Helper()
24+
raw, ok := tool[key]
25+
if !ok {
26+
t.Fatalf("tool %v: no %s", tool["name"], key)
27+
}
28+
b, err := json.Marshal(raw)
29+
if err != nil {
30+
t.Fatalf("tool %v: %s does not marshal: %v", tool["name"], key, err)
31+
}
32+
var m map[string]any
33+
if err := json.Unmarshal(b, &m); err != nil {
34+
t.Fatalf("tool %v: %s is not a JSON object: %v", tool["name"], key, err)
35+
}
36+
return m
37+
}
38+
39+
// Every tool declares both schemas, both are well formed, and every required
40+
// key is one the schema actually declares. Handlers and definitions must also
41+
// agree: a handler with no definition is invisible, a definition with no
42+
// handler is a dead promise.
43+
func TestToolSchemasAreComplete(t *testing.T) {
44+
defs := toolDefs()
45+
seen := map[string]bool{}
46+
for _, d := range defs {
47+
name, _ := d["name"].(string)
48+
if name == "" {
49+
t.Fatalf("tool definition without a name: %v", d)
50+
}
51+
if seen[name] {
52+
t.Errorf("duplicate tool definition: %s", name)
53+
}
54+
seen[name] = true
55+
if desc, _ := d["description"].(string); desc == "" {
56+
t.Errorf("tool %s: no description", name)
57+
}
58+
if toolHandlers[name] == nil {
59+
t.Errorf("tool %s: declared but has no handler", name)
60+
}
61+
for _, key := range []string{"inputSchema", "outputSchema"} {
62+
s := schemaOf(t, d, key)
63+
if s["type"] != "object" {
64+
t.Errorf("tool %s: %s type = %v, want object", name, key, s["type"])
65+
}
66+
props, ok := s["properties"].(map[string]any)
67+
if !ok {
68+
t.Errorf("tool %s: %s properties = %v, want an object", name, key, s["properties"])
69+
continue
70+
}
71+
req, _ := s["required"].([]any)
72+
for _, r := range req {
73+
if _, declared := props[r.(string)]; !declared {
74+
t.Errorf("tool %s: %s requires %q but does not declare it", name, key, r)
75+
}
76+
}
77+
}
78+
}
79+
for name := range toolHandlers {
80+
if !seen[name] {
81+
t.Errorf("handler %s: reachable but absent from tools/list", name)
82+
}
83+
}
84+
}
85+
86+
// checkAgainstSchema asserts that a payload a tool actually emitted satisfies
87+
// the outputSchema that tool advertises: every required key present, every key
88+
// declared, and every declared type honoured. This is the guard that keeps the
89+
// declaration honest as the handlers change.
90+
func checkAgainstSchema(t *testing.T, name string, payload map[string]any) {
91+
t.Helper()
92+
schema := schemaOf(t, toolDef(t, name), "outputSchema")
93+
props, _ := schema["properties"].(map[string]any)
94+
req, _ := schema["required"].([]any)
95+
96+
for _, r := range req {
97+
if _, ok := payload[r.(string)]; !ok {
98+
t.Errorf("tool %s: emitted payload lacks required key %q: %v", name, r, payload)
99+
}
100+
}
101+
for k, v := range payload {
102+
spec, declared := props[k].(map[string]any)
103+
if !declared {
104+
t.Errorf("tool %s: emitted undeclared key %q", name, k)
105+
continue
106+
}
107+
checkType(t, name+"."+k, spec, v)
108+
}
109+
}
110+
111+
// checkType verifies one value against its schema fragment, descending into
112+
// arrays and nested objects (the frame and variable item shapes).
113+
func checkType(t *testing.T, path string, spec map[string]any, v any) {
114+
t.Helper()
115+
switch spec["type"] {
116+
case "string":
117+
s, ok := v.(string)
118+
if !ok {
119+
t.Errorf("%s = %v (%T), want string", path, v, v)
120+
return
121+
}
122+
if enum, ok := spec["enum"].([]any); ok {
123+
for _, e := range enum {
124+
if s == e {
125+
return
126+
}
127+
}
128+
t.Errorf("%s = %q, not in enum %v", path, s, enum)
129+
}
130+
case "integer":
131+
// JSON numbers decode to float64; integrality is what we assert.
132+
f, ok := v.(float64)
133+
if !ok || f != float64(int64(f)) {
134+
t.Errorf("%s = %v (%T), want integer", path, v, v)
135+
}
136+
case "boolean":
137+
if _, ok := v.(bool); !ok {
138+
t.Errorf("%s = %v (%T), want boolean", path, v, v)
139+
}
140+
case "array":
141+
items, ok := v.([]any)
142+
if !ok {
143+
t.Errorf("%s = %v (%T), want array", path, v, v)
144+
return
145+
}
146+
itemSpec, _ := spec["items"].(map[string]any)
147+
for _, it := range items {
148+
checkType(t, path+"[]", itemSpec, it)
149+
}
150+
case "object":
151+
m, ok := v.(map[string]any)
152+
if !ok {
153+
t.Errorf("%s = %v (%T), want object", path, v, v)
154+
return
155+
}
156+
props, _ := spec["properties"].(map[string]any)
157+
req, _ := spec["required"].([]any)
158+
for _, r := range req {
159+
if _, ok := m[r.(string)]; !ok {
160+
t.Errorf("%s lacks required key %q", path, r)
161+
}
162+
}
163+
for k, mv := range m {
164+
sub, declared := props[k].(map[string]any)
165+
if !declared {
166+
t.Errorf("%s has undeclared key %q", path, k)
167+
continue
168+
}
169+
checkType(t, path+"."+k, sub, mv)
170+
}
171+
}
172+
}
173+
174+
// Drive a real session and hold every tool's actual output against its declared
175+
// outputSchema — including both success shapes of the tools that have two (a
176+
// run that stops vs one that finishes, a source fetched whole vs as a snippet).
177+
func TestToolOutputMatchesDeclaredSchema(t *testing.T) {
178+
srv := newTestServer()
179+
tr := newChanTransport()
180+
go srv.Serve(tr)
181+
c := &testClient{t: t, tr: tr}
182+
c.call("initialize", map[string]any{})
183+
184+
check := func(name string, args map[string]any) map[string]any {
185+
payload := c.callTool(name, args)
186+
checkAgainstSchema(t, name, payload)
187+
return payload
188+
}
189+
190+
check("set_breakpoints", map[string]any{"source": "loop", "lines": []any{2.0}})
191+
192+
ev := check("launch", map[string]any{"program": "loop"})
193+
if ev["event"] != "stopped" {
194+
t.Fatalf("expected a stop inside add(), got %v", ev)
195+
}
196+
// Paused: the inspection tools and both get_source shapes.
197+
check("stack", nil)
198+
check("variables", map[string]any{"frame": 0.0})
199+
check("evaluate", map[string]any{"expr": "a"})
200+
check("get_source", map[string]any{"id": "loop"}) // whole text
201+
check("get_source", map[string]any{"id": "loop", "line": 2.0, "context": 1.0}) // snippet
202+
check("pause", nil)
203+
204+
// Run to the end so the "finished" variant of the control-tool schema is
205+
// exercised too, not just "stopped".
206+
for ev["event"] == "stopped" {
207+
ev = check("continue", nil)
208+
}
209+
if ev["event"] != "finished" {
210+
t.Fatalf("program did not finish: %v", ev)
211+
}
212+
}

‎debugmcp/tools.go‎

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,23 @@ var toolHandlers = map[string]toolHandler{
2626
}
2727

2828
// toolDefs is the tools/list payload: each tool's name, description and JSON
29-
// Schema for its arguments.
29+
// Schema for its arguments and for its result.
30+
//
31+
// Every tool here reports success as structuredContent, so every tool declares
32+
// an outputSchema — that is the whole contract a client can rely on, and
33+
// without it the structured object is opaque. The schemas describe successful
34+
// results only: a tool-level failure comes back as an isError result carrying
35+
// {"error": …}, which is a different shape by design.
36+
//
37+
// Required keys are the ones present in *every* success. Tools with two success
38+
// shapes (a run that stops vs finishes, a source fetched whole vs as a snippet)
39+
// therefore require only the key that discriminates them, and declare the union
40+
// of the optional rest.
3041
func toolDefs() []map[string]any {
3142
obj := func(props map[string]any, required ...string) map[string]any {
43+
if props == nil {
44+
props = map[string]any{} // JSON Schema wants an object, not null
45+
}
3246
schema := map[string]any{"type": "object", "properties": props}
3347
if len(required) > 0 {
3448
schema["required"] = required
@@ -37,14 +51,47 @@ func toolDefs() []map[string]any {
3751
}
3852
str := map[string]any{"type": "string"}
3953
intt := map[string]any{"type": "integer"}
54+
boolt := map[string]any{"type": "boolean"}
55+
arr := func(items map[string]any) map[string]any {
56+
return map[string]any{"type": "array", "items": items}
57+
}
58+
59+
// runEvent is what the control tools return: the program either stopped or
60+
// finished. Only "event" tells the two apart, so it is the only certainty.
61+
runEvent := obj(map[string]any{
62+
"event": map[string]any{"type": "string", "enum": []string{"stopped", "finished"}},
63+
"reason": str,
64+
"source": str,
65+
"line": intt,
66+
"function": str,
67+
"depth": intt,
68+
"results": arr(str),
69+
"error": str,
70+
}, "event")
71+
frame := obj(map[string]any{
72+
"level": intt, "source": str, "line": intt, "function": str, "what": str,
73+
}, "level", "source", "line", "function", "what")
74+
variable := obj(map[string]any{"name": str, "value": str, "kind": str},
75+
"name", "value", "kind")
76+
77+
// The control tools differ only in description; their contract is identical.
78+
control := func(name, description string) map[string]any {
79+
return map[string]any{
80+
"name": name, "description": description,
81+
"inputSchema": obj(nil), "outputSchema": runEvent,
82+
}
83+
}
84+
4085
return []map[string]any{
4186
{
4287
"name": "set_breakpoints",
4388
"description": "Set the breakpoints for a source (a program id). Replaces any previous set for that source. Safe before or during a run.",
4489
"inputSchema": obj(map[string]any{
4590
"source": str,
46-
"lines": map[string]any{"type": "array", "items": intt},
91+
"lines": arr(intt),
4792
}, "source", "lines"),
93+
"outputSchema": obj(map[string]any{"source": str, "lines": arr(intt)},
94+
"source", "lines"),
4895
},
4996
{
5097
"name": "launch",
@@ -53,27 +100,43 @@ func toolDefs() []map[string]any {
53100
"program": str,
54101
"source": str,
55102
}, "program"),
103+
"outputSchema": runEvent,
104+
},
105+
control("continue", "Resume until the next breakpoint or program end. Returns a 'stopped' or 'finished' event."),
106+
control("step_over", "Step to the next line in the current frame (calls run without stopping). Returns a 'stopped' or 'finished' event."),
107+
control("step_into", "Step to the next line, descending into calls. Returns a 'stopped' or 'finished' event."),
108+
control("step_out", "Run until the current function returns. Returns a 'stopped' or 'finished' event."),
109+
{
110+
"name": "pause",
111+
"description": "Request a stop at the next line (asynchronous).",
112+
"inputSchema": obj(nil),
113+
"outputSchema": obj(map[string]any{"ok": boolt}, "ok"),
114+
},
115+
{
116+
"name": "stack",
117+
"description": "List the call stack at the current stop (innermost first).",
118+
"inputSchema": obj(nil),
119+
"outputSchema": obj(map[string]any{"frames": arr(frame)}, "frames"),
56120
},
57-
{"name": "continue", "description": "Resume until the next breakpoint or program end. Returns a 'stopped' or 'finished' event.", "inputSchema": obj(nil)},
58-
{"name": "step_over", "description": "Step to the next line in the current frame (calls run without stopping). Returns a 'stopped' or 'finished' event.", "inputSchema": obj(nil)},
59-
{"name": "step_into", "description": "Step to the next line, descending into calls. Returns a 'stopped' or 'finished' event.", "inputSchema": obj(nil)},
60-
{"name": "step_out", "description": "Run until the current function returns. Returns a 'stopped' or 'finished' event.", "inputSchema": obj(nil)},
61-
{"name": "pause", "description": "Request a stop at the next line (asynchronous).", "inputSchema": obj(nil)},
62-
{"name": "stack", "description": "List the call stack at the current stop (innermost first).", "inputSchema": obj(nil)},
63121
{
64-
"name": "variables",
65-
"description": "List the locals, upvalues and varargs of a frame at the current stop.",
66-
"inputSchema": obj(map[string]any{"frame": intt}),
122+
"name": "variables",
123+
"description": "List the locals, upvalues and varargs of a frame at the current stop.",
124+
"inputSchema": obj(map[string]any{"frame": intt}),
125+
"outputSchema": obj(map[string]any{"variables": arr(variable)}, "variables"),
67126
},
68127
{
69-
"name": "evaluate",
70-
"description": "Evaluate a Lua expression (or statement) in the scope of a frame at the current stop.",
71-
"inputSchema": obj(map[string]any{"expr": str, "frame": intt}, "expr"),
128+
"name": "evaluate",
129+
"description": "Evaluate a Lua expression (or statement) in the scope of a frame at the current stop.",
130+
"inputSchema": obj(map[string]any{"expr": str, "frame": intt}, "expr"),
131+
"outputSchema": obj(map[string]any{"result": str}, "result"),
72132
},
73133
{
74134
"name": "get_source",
75135
"description": "Fetch source text by program id — the whole text, or a snippet around 'line' when given. Defaults to the current stop's source. Lets a client with no local source show where it is.",
76136
"inputSchema": obj(map[string]any{"id": str, "line": intt, "context": intt}),
137+
"outputSchema": obj(map[string]any{
138+
"id": str, "source": str, "line": intt, "snippet": str,
139+
}, "id"),
77140
},
78141
}
79142
}

0 commit comments

Comments
 (0)