Skip to content

Commit da3c591

Browse files
committed
Add sortable tool contents with claimed hint summaries
1 parent 545579c commit da3c591

4 files changed

Lines changed: 71 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ reports use the same hierarchy. Long URLs and description text wrap within
3333
the page. **Sort by** orders tools by name or any hint, and **Reverse** flips
3434
the order. Hints sort false first using the displayed defaults when missing;
3535
read-only “n/a” values stay last. Sorting reuses the snapshot, and publishing
36-
preserves the displayed order.
36+
preserves the displayed order. The **Tools** contents list links to each tool,
37+
summarizes its applicable claimed hints, and follows the same sort order.
38+
Published reports include the contents too.
3739
Inspector diagnostics, including the authorization URL, appear immediately in
3840
the terminal if you need to open the URL yourself.
3941

‎form.html‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
button { font: inherit; padding: .4rem 1.2rem; margin: .5rem .5rem 0 0; }
1212
button:disabled { opacity: .6; cursor: wait; }
1313
h2 { font-size: 1.2rem; }
14+
h2, h3 { scroll-margin-top: 1rem; }
15+
.contents { padding-left: 1.5rem; }
16+
.contents li { margin: .5rem 0; }
1417
.tool { border-top: 1px solid #ddd; padding: 1rem 0; }
1518
.tool h3 { font: 600 1.1rem/1.5 ui-monospace, monospace; margin: 0 0 .5rem; }
1619
.tool h4 { font-size: 1rem; margin: 1rem 0 .3rem; }
@@ -64,6 +67,7 @@ <h1>MCP tool annotation hints</h1>
6467
<form method="post" action="/" hx-post="/" hx-target="#result" hx-select="#result" hx-swap="outerHTML" hx-disabled-elt="button, #result select, #result input[type=checkbox]">
6568
<input type="hidden" name="payload" value="{{.Snapshot}}">
6669
{{if .Rows}}
70+
<h2 id="tools">Tools</h2>
6771
<div class="sort-controls">
6872
<label>Sort by <select name="sort" onchange="this.form.requestSubmit()">
6973
<option value="">Server order</option>
@@ -73,10 +77,19 @@ <h1>MCP tool annotation hints</h1>
7377
<label><input type="checkbox" name="reverse" value="1" {{if .Reverse}}checked{{end}} onchange="this.form.requestSubmit()"> Reverse</label>
7478
</div>
7579
<p class="hint">Hints sort false first (true first when reversed). Missing hints use their displayed defaults; n/a stays last.</p>
76-
<h2>Tools</h2>
80+
<nav aria-label="Tool contents">
81+
<ul class="contents">
82+
{{range .Rows}}
83+
<li><a href="#{{.ID}}"><code>{{.Name}}</code></a>
84+
{{range .Claims}}<span class="b {{if .Risky}}risk{{else}}safe{{end}}">{{.Name}}: claimed {{.Value}}</span>
85+
{{else}}<span class="hint">No hints declared.</span>{{end}}
86+
</li>
87+
{{end}}
88+
</ul>
89+
</nav>
7790
{{range .Rows}}
7891
<section class="tool">
79-
<h3>{{.Name}}</h3>
92+
<h3 id="{{.ID}}">{{.Name}}</h3>
8093
{{if .Description}}<h4>Description</h4><p class="description">{{.Description}}</p>{{end}}
8194
<h4>Annotations</h4>
8295
{{if .None}}<p class="hint">No annotations declared.</p>{{end}}
@@ -91,6 +104,7 @@ <h4>Annotations</h4>
91104
</li>
92105
{{end}}
93106
</ul>
107+
<a class="hint" href="#tools">Back to tools</a>
94108
</section>
95109
{{end}}
96110

‎main.go‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,23 @@ type Hint struct {
4848
}
4949

5050
type Row struct {
51+
ID string
5152
Name string
5253
Description string
5354
None bool // no annotations declared at all
5455
Hints [4]Hint // readOnly, destructive, idempotent, openWorld
5556
}
5657

58+
func (r Row) Claims() []Hint {
59+
var claims []Hint
60+
for _, h := range r.Hints {
61+
if h.Declared && !h.Moot {
62+
claims = append(claims, h)
63+
}
64+
}
65+
return claims
66+
}
67+
5768
// The spec's defaults are deliberately the worst case.
5869
var defaults = [4]Hint{
5970
{Name: "readOnlyHint", Value: false},
@@ -62,8 +73,8 @@ var defaults = [4]Hint{
6273
{Name: "openWorldHint", Value: true},
6374
}
6475

65-
func buildRow(t Tool) Row {
66-
r := Row{Name: t.Name, Description: t.Description, None: t.Annotations == nil}
76+
func buildRow(t Tool, index int) Row {
77+
r := Row{ID: fmt.Sprintf("tool-%d", index), Name: t.Name, Description: t.Description, None: t.Annotations == nil}
6778
var ptrs [4]*bool
6879
if t.Annotations != nil {
6980
a := t.Annotations
@@ -174,7 +185,21 @@ func markdown(server, fetched string, rows []Row) string {
174185
b.WriteString("No tools returned.\n\n")
175186
}
176187
for _, r := range rows {
177-
fmt.Fprintf(&b, "### `%s`\n\n", r.Name)
188+
fmt.Fprintf(&b, "- <a href=\"#%s\"><code>%s</code></a> — ", r.ID, template.HTMLEscapeString(r.Name))
189+
var claims []string
190+
for _, h := range r.Claims() {
191+
claims = append(claims, fmt.Sprintf("`%s`: %s", h.Name, cell(h)))
192+
}
193+
if len(claims) == 0 {
194+
b.WriteString("No hints declared.")
195+
} else {
196+
b.WriteString(strings.Join(claims, "; "))
197+
}
198+
b.WriteString("\n")
199+
}
200+
b.WriteString("\n")
201+
for _, r := range rows {
202+
fmt.Fprintf(&b, "<a name=\"%s\"></a>\n\n### `%s`\n\n", r.ID, r.Name)
178203
if r.Description != "" {
179204
fmt.Fprintf(&b, "#### Description\n\n<p>%s</p>\n\n",
180205
strings.ReplaceAll(template.HTMLEscapeString(r.Description), "\n", "<br>\n"))
@@ -300,8 +325,8 @@ func newHandler() http.Handler {
300325
data.Payload = data.Snapshot
301326
}
302327
data.Server, data.Fetched = p.Server, p.Fetched
303-
for _, t := range p.Tools {
304-
data.Rows = append(data.Rows, buildRow(t))
328+
for i, t := range p.Tools {
329+
data.Rows = append(data.Rows, buildRow(t, i))
305330
}
306331
sortRows(data.Rows, data.Sort, data.Reverse)
307332
data.Done = true

‎main_test.go‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ printf '%s\n' '{"tools":[
9999
t.Errorf("invalid fetched timestamp: %v", err)
100100
}
101101

102-
sections := regexp.MustCompile(`(?s)<section class="tool">\s*<h3>(.*?)</h3>(.*?)</section>`).FindAllStringSubmatch(body, -1)
102+
sections := regexp.MustCompile(`(?s)<section class="tool">\s*<h3 id="[^"]+">(.*?)</h3>(.*?)</section>`).FindAllStringSubmatch(body, -1)
103103
cases := []struct {
104104
name string
105105
hints []string
@@ -210,6 +210,12 @@ printf 'https://gist.github.com/example/snapshot\n'`,
210210
if p.Tools[0].Description != description || !strings.Contains(w.Body.String(), `<p class="description">`+html.EscapeString(description)+"</p>") || strings.Contains(w.Body.String(), "<script>alert(1)</script>") {
211211
t.Fatal("description was lost or rendered as HTML")
212212
}
213+
contents := regexp.MustCompile(`(?s)<nav aria-label="Tool contents">(.*?)</nav>`).FindStringSubmatch(w.Body.String())
214+
if len(contents) != 2 || !strings.Contains(contents[1], "readOnlyHint: claimed true") ||
215+
!strings.Contains(contents[1], "No hints declared.") || strings.Contains(contents[1], "destructiveHint") ||
216+
strings.Contains(contents[1], "idempotentHint") || strings.Contains(contents[1], "openWorldHint") {
217+
t.Fatalf("contents must summarize applicable claims only: %v", contents)
218+
}
213219
for _, fail := range []string{"true", "false"} {
214220
t.Run("gist_failure="+fail, func(t *testing.T) {
215221
t.Setenv("MCPHINTS_SMOKE_FAIL", fail)
@@ -226,7 +232,8 @@ printf 'https://gist.github.com/example/snapshot\n'`,
226232
}
227233
for _, want := range []string{
228234
"- **Server:** `" + server + "`", "- **Fetched:** " + fetched,
229-
"## Tools\n\n### `another`", "### `read_only`", "#### Description\n\n<p>" + strings.ReplaceAll(html.EscapeString(description), "\n", "<br>\n") + "</p>",
235+
"## Tools\n\n- <a href=\"#tool-1\"><code>another</code></a> — No hints declared.\n- <a href=\"#tool-0\"><code>read_only</code></a> — `readOnlyHint`: 🟢 claimed true\n\n",
236+
"<a name=\"tool-1\"></a>\n\n### `another`", "<a name=\"tool-0\"></a>\n\n### `read_only`", "#### Description\n\n<p>" + strings.ReplaceAll(html.EscapeString(description), "\n", "<br>\n") + "</p>",
230237
"#### Annotations\n\n- `readOnlyHint`: 🟢 claimed true\n- `destructiveHint`: n/a — read-only\n- `idempotentHint`: n/a — read-only\n- `openWorldHint`: ⚠️ assumed true",
231238
} {
232239
if !strings.Contains(string(md), want) {
@@ -253,7 +260,7 @@ func TestSortSnapshot(t *testing.T) {
253260
// No executables: sorting must never fetch or publish.
254261
t.Setenv("PATH", t.TempDir())
255262
const source = `{"server":"https://example.test/mcp","fetched":"2020-01-02T03:04:05Z","tools":[
256-
{"name":"z_missing"},
263+
{"name":"z_missing","annotations":{}},
257264
{"name":"b_safe","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},
258265
{"name":"c_risky","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true}},
259266
{"name":"a_readonly","annotations":{"readOnlyHint":true}}
@@ -282,10 +289,20 @@ func TestSortSnapshot(t *testing.T) {
282289
form.Set("reverse", "1")
283290
}
284291
w := postForm(t, handler, form)
285-
var names []string
286-
for _, heading := range regexp.MustCompile(`<h3>([^<]+)</h3>`).FindAllStringSubmatch(w.Body.String(), -1) {
292+
var names, linkedNames []string
293+
for _, heading := range regexp.MustCompile(`<h3 id="[^"]+">([^<]+)</h3>`).FindAllStringSubmatch(w.Body.String(), -1) {
287294
names = append(names, heading[1])
288295
}
296+
ids := map[string]string{"z_missing": "tool-0", "b_safe": "tool-1", "c_risky": "tool-2", "a_readonly": "tool-3"}
297+
for _, link := range regexp.MustCompile(`<a href="#(tool-[^"]+)"><code>([^<]+)</code></a>`).FindAllStringSubmatch(w.Body.String(), -1) {
298+
linkedNames = append(linkedNames, link[2])
299+
if link[1] != ids[link[2]] || !strings.Contains(w.Body.String(), `<h3 id="`+link[1]+`">`+link[2]+`</h3>`) {
300+
t.Errorf("contents link changed or has no matching heading: %s", link[0])
301+
}
302+
}
303+
if !slices.Equal(linkedNames, names) {
304+
t.Errorf("contents order = %v, want %v", linkedNames, names)
305+
}
289306
if strings.Join(names, " ") != tc.want {
290307
t.Errorf("sort %q, reverse %v: got %v, want %s", tc.key, tc.reverse, names, tc.want)
291308
}

0 commit comments

Comments
 (0)