Type the REST API's responses

Every tool now returns a Go struct (apitypes.go) instead of a map, and
typeschema.go derives the OpenAPI response schemas from those types by
reflection (json, doc and enum tags), so the document cannot drift from
what is sent. Text results are documented as text/plain, settings in
answers use the typed settings schema, and edits answer with EditResponse.

TestResponsesMatchTheirSchemas checks real responses against the
generated schemas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-05 08:10:59 +01:00
co-authored by Claude Sonnet 5.5
parent e3209569bd
commit 98b0ebc130
8 changed files with 436 additions and 79 deletions
+27 -15
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"reflect"
"slices"
"strconv"
"strings"
@@ -41,6 +42,14 @@ var apiRoutes = []apiRoute{
// etagHeader documents the ETag header of the answers about the settings.
var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}}
// resultContent is the 200 response's content: the result's type as JSON, or text.
func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any {
if tool.output == nil {
return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}}
}
return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}}
}
func routeText(rt apiRoute, tool *mcpTool) string {
if rt.description != "" {
return rt.description
@@ -107,15 +116,11 @@ func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
_, _ = io.WriteString(w, text+"\n")
return
}
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
}
if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
switch r := res.(type) {
case SettingsResponse:
w.Header().Set("ETag", `"`+r.ETag+`"`)
case EditResponse:
w.Header().Set("ETag", `"`+r.ETag+`"`)
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
@@ -193,6 +198,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
errResp := func(d string) map[string]any {
return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
}
b := newSchemaBuilder()
paths := map[string]any{}
for _, rt := range apiRoutes {
tool := toolNamed(rt.tool)
@@ -204,10 +210,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
"security": []any{map[string]any{"bearer": []string{}}},
"x-scope": tool.scope,
"responses": map[string]any{
"200": map[string]any{"description": "The result.", "content": map[string]any{
"application/json": map[string]any{"schema": map[string]any{}},
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
}},
"200": map[string]any{"description": "The result.", "content": resultContent(b, tool)},
"401": errResp("No or an unknown token."),
"403": errResp("The token's scope does not allow this."),
},
@@ -253,11 +256,20 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
}}
if schema != nil {
for p, ops := range areaPaths(schema) {
for p, ops := range areaPaths(schema, b.of(reflect.TypeOf(EditResponse{}))) {
paths[p] = ops
}
}
addCurlSamples(paths, "https://"+host)
if schema != nil { // the settings in the answers are the typed settings
if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok {
sr["properties"].(map[string]any)["settings"] = schema
}
}
schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}
for k, v := range b.defs {
schemas[k] = v
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
@@ -274,7 +286,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
"paths": paths,
"components": map[string]any{
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
"schemas": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}},
"schemas": schemas,
},
}
}