- {canTrustTool ? (
+ {!unavailable && canTrustTool ? (
trusted ? (
diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts
index c51a81a006..9d0f3bcd24 100644
--- a/desktop/frontend/src/lib/types.ts
+++ b/desktop/frontend/src/lib/types.ts
@@ -696,6 +696,7 @@ export interface MCPToolView {
name: string;
description: string;
readOnlyHint?: boolean;
+ schemaError?: string;
}
export interface SkillView {
name: string;
diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts
index b5de962b5d..42a1b505c6 100644
--- a/desktop/frontend/src/locales/en.ts
+++ b/desktop/frontend/src/locales/en.ts
@@ -234,6 +234,7 @@ export const en = {
"caps.rawLog": "Raw log",
"caps.copyLog": "Copy log",
"caps.counts": "{tools} tools · {prompts} prompts · {resources} resources",
+ "caps.schemaIssues": "{count} unavailable",
"caps.connected": "connected",
"caps.failed": "failed to connect",
"caps.tools": "Tools",
@@ -307,6 +308,7 @@ export const en = {
"caps.expandTools": "Expand tools",
"caps.collapseTools": "Collapse tools",
"caps.noToolDetails": "This connection did not return tool details.",
+ "caps.toolUnavailable": "Unavailable",
"caps.trustReadOnly": "Pre-trust",
"caps.trustedReadOnly": "Trusted",
"caps.trustedReadOnlyTools": "Remembered read-only",
diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts
index 0a35676174..6008ea0ab7 100644
--- a/desktop/frontend/src/locales/zh-TW.ts
+++ b/desktop/frontend/src/locales/zh-TW.ts
@@ -150,6 +150,7 @@ export const zhTW: Record = {
"caps.rawLog": "原始日誌",
"caps.copyLog": "複製日誌",
"caps.counts": "{tools} 工具 · {prompts} 提示 · {resources} 資源",
+ "caps.schemaIssues": "{count} 個不可用",
"caps.connected": "已連線",
"caps.failed": "連線失敗",
"caps.tools": "工具",
@@ -223,6 +224,7 @@ export const zhTW: Record = {
"caps.expandTools": "展開工具列表",
"caps.collapseTools": "收起工具列表",
"caps.noToolDetails": "當前連線未返回工具明細。",
+ "caps.toolUnavailable": "不可用",
"caps.trustReadOnly": "預先信任",
"caps.trustedReadOnly": "已信任",
"caps.trustedReadOnlyTools": "已記住唯讀",
diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts
index 1ffea558cb..ca58c578a4 100644
--- a/desktop/frontend/src/locales/zh.ts
+++ b/desktop/frontend/src/locales/zh.ts
@@ -235,6 +235,7 @@ export const zh: Record = {
"caps.rawLog": "原始日志",
"caps.copyLog": "复制日志",
"caps.counts": "{tools} 工具 · {prompts} 提示 · {resources} 资源",
+ "caps.schemaIssues": "{count} 个不可用",
"caps.connected": "已连接",
"caps.failed": "连接失败",
"caps.tools": "工具",
@@ -308,6 +309,7 @@ export const zh: Record = {
"caps.expandTools": "展开工具列表",
"caps.collapseTools": "收起工具列表",
"caps.noToolDetails": "当前连接未返回工具明细。",
+ "caps.toolUnavailable": "不可用",
"caps.trustReadOnly": "预先信任",
"caps.trustedReadOnly": "已信任",
"caps.trustedReadOnlyTools": "已记住只读",
diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css
index 48351c6fb3..bb8cdbe598 100644
--- a/desktop/frontend/src/styles.css
+++ b/desktop/frontend/src/styles.css
@@ -10863,6 +10863,16 @@ body > .mermaid-diagram--fullscreen {
line-height: 1;
white-space: nowrap;
}
+.cap-tool-hint--error {
+ gap: 3px;
+ border-color: color-mix(in srgb, var(--warn) 34%, transparent);
+ color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 8%, transparent);
+}
+.cap-tool--unavailable .cap-tool__name,
+.cap-tool--unavailable .cap-tool__desc {
+ color: var(--fg-faint);
+}
.cap-tool-trust-stack {
display: flex;
flex-wrap: wrap;
diff --git a/desktop/go.mod b/desktop/go.mod
index fe4400bc95..2f87d51792 100644
--- a/desktop/go.mod
+++ b/desktop/go.mod
@@ -52,6 +52,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/samber/lo v1.53.0 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
diff --git a/desktop/go.sum b/desktop/go.sum
index 2d7e4ff7d8..d39fb33449 100644
--- a/desktop/go.sum
+++ b/desktop/go.sum
@@ -20,6 +20,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
@@ -89,6 +91,8 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
diff --git a/go.mod b/go.mod
index 1f458f1927..4851ad40f7 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
github.com/mattn/go-runewidth v0.0.24
github.com/rivo/uniseg v0.4.7
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/pflag v1.0.6
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-javascript v0.25.0
diff --git a/go.sum b/go.sum
index 8797860568..05c332151f 100644
--- a/go.sum
+++ b/go.sum
@@ -45,6 +45,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -87,6 +89,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/internal/cli/mcp_manager_view.go b/internal/cli/mcp_manager_view.go
index 9115f79bd4..c77067766c 100644
--- a/internal/cli/mcp_manager_view.go
+++ b/internal/cli/mcp_manager_view.go
@@ -180,7 +180,11 @@ func (p *mcpManager) renderTools(width int) string {
limit = mcpToolMaxRows
}
for _, t := range v.ToolList[:limit] {
- desc := viewCompactText(t.Description, viewBudget(width, 24))
+ desc := t.Description
+ if t.SchemaError != "" {
+ desc = "unavailable: " + t.SchemaError
+ }
+ desc = viewCompactText(desc, viewBudget(width, 24))
fmt.Fprintf(&b, " %-20s %s\n", t.Name, viewMeta(desc))
}
if extra := len(v.ToolList) - limit; extra > 0 {
diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go
index 62ce3fb3b9..f2bf7ec61c 100644
--- a/internal/cli/mcp_test.go
+++ b/internal/cli/mcp_test.go
@@ -230,6 +230,26 @@ func TestRenderMCPStatusCapsLongSections(t *testing.T) {
}
}
+func TestRenderMCPStatusShowsQuarantinedTools(t *testing.T) {
+ got := renderMCPStatus(200,
+ []plugin.ServerStatus{{
+ Name: "yakit", Transport: "stdio", Tools: 1,
+ ToolList: []plugin.ToolInfo{
+ {Name: "echo", Description: "available"},
+ {Name: "generate_yso_bytes", SchemaError: "invalid input schema: bad type at /properties/options/items/type"},
+ },
+ }},
+ nil,
+ nil,
+ nil,
+ )
+ for _, want := range []string{"1 tool", "1 unavailable tool", "unavailable tools", "generate_yso_bytes", "invalid input schema"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("rendered MCP status missing %q:\n%s", want, got)
+ }
+ }
+}
+
func TestMCPCapabilitiesTextUsesAdvertisedTools(t *testing.T) {
if got := mcpCapabilitiesText(mcpServerView{HasTools: true}); got != "tools" {
t.Fatalf("mcpCapabilitiesText = %q, want tools", got)
diff --git a/internal/cli/mcp_view.go b/internal/cli/mcp_view.go
index bb9376d2fe..054ba61fc7 100644
--- a/internal/cli/mcp_view.go
+++ b/internal/cli/mcp_view.go
@@ -63,6 +63,10 @@ func writeMCPServer(b *strings.Builder, width int, s plugin.ServerStatus, prompt
transport = "unknown"
}
meta := fmt.Sprintf("(%s) %s · %s · %s", transport, countText(s.Tools, "tool"), countText(len(prompts), "prompt"), countText(len(resources), "resource"))
+ invalidTools := invalidMCPTools(s.ToolList)
+ if len(invalidTools) > 0 {
+ meta += " · " + countText(len(invalidTools), "unavailable tool")
+ }
name := viewCompactText(s.Name, viewBudget(width, 4+2+1+visibleWidth(meta)))
fmt.Fprintf(b, " %s %s %s\n", accent("✓"), bold(name), viewMeta(meta))
if len(prompts) > 0 {
@@ -71,6 +75,29 @@ func writeMCPServer(b *strings.Builder, width int, s plugin.ServerStatus, prompt
if len(resources) > 0 {
writeMCPResourceList(b, width, resources)
}
+ if len(invalidTools) > 0 {
+ b.WriteString(viewSubhead(" unavailable tools") + "\n")
+ limit := len(invalidTools)
+ if limit > mcpMaxItemsPerSection {
+ limit = mcpMaxItemsPerSection
+ }
+ for _, t := range invalidTools[:limit] {
+ writeMCPItem(b, width, " ", t.Name, t.SchemaError)
+ }
+ if extra := len(invalidTools) - limit; extra > 0 {
+ fmt.Fprintf(b, " %s\n", viewMore(extra, "unavailable tools"))
+ }
+ }
+}
+
+func invalidMCPTools(tools []plugin.ToolInfo) []plugin.ToolInfo {
+ out := make([]plugin.ToolInfo, 0)
+ for _, t := range tools {
+ if t.SchemaError != "" {
+ out = append(out, t)
+ }
+ }
+ return out
}
func writeMCPFailure(b *strings.Builder, width int, f plugin.Failure) {
diff --git a/internal/plugin/cache.go b/internal/plugin/cache.go
index fc631d0a2e..cf788a1517 100644
--- a/internal/plugin/cache.go
+++ b/internal/plugin/cache.go
@@ -131,9 +131,23 @@ func LoadCachedSchemaAny(name, expectedHash string) (cs *CachedSchema, ok bool,
if out.Version != cacheVersion {
return nil, false, false
}
+ out.Tools = filterValidCachedTools(out.Tools)
return &out, true, out.SpecHash == expectedHash
}
+func filterValidCachedTools(tools []CachedTool) []CachedTool {
+ out := make([]CachedTool, 0, len(tools))
+ for _, t := range tools {
+ schema, err := normalizeAndValidateToolSchema(t.Schema)
+ if err != nil {
+ continue
+ }
+ t.Schema = schema
+ out = append(out, t)
+ }
+ return out
+}
+
// SaveCachedSchema atomically writes cs under name. Best-effort: an error
// is logged at debug level and dropped. Uses tmpfile + os.Rename in the
// parent dir so a crash mid-write can't leave a half-written JSON behind
diff --git a/internal/plugin/cache_test.go b/internal/plugin/cache_test.go
index 5f2028f8f8..35e911214e 100644
--- a/internal/plugin/cache_test.go
+++ b/internal/plugin/cache_test.go
@@ -75,6 +75,34 @@ func TestCacheRoundTrip(t *testing.T) {
}
}
+func TestCacheLoadQuarantinesMalformedToolSchema(t *testing.T) {
+ redirectCache(t)
+ spec := sampleSpec()
+ hash := SpecFingerprint(spec)
+ cs := sampleCachedSchema(hash)
+ cs.Tools = append(cs.Tools, CachedTool{
+ Name: "generate_yso_bytes",
+ Schema: json.RawMessage(`{
+ "type":"object",
+ "properties":{"options":{"type":"array","items":{"key":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}}}}
+ }`),
+ })
+
+ if err := SaveCachedSchema(spec.Name, cs); err != nil {
+ t.Fatalf("SaveCachedSchema: %v", err)
+ }
+ got, ok := LoadCachedSchema(spec.Name, hash)
+ if !ok {
+ t.Fatal("LoadCachedSchema: miss after save")
+ }
+ if len(got.Tools) != 1 || got.Tools[0].Name != "do_thing" {
+ t.Fatalf("cached tools = %+v, want only valid do_thing", got.Tools)
+ }
+ if schema := string(got.Tools[0].Schema); schema != `{"properties":{},"type":"object"}` {
+ t.Fatalf("valid cached schema = %s", schema)
+ }
+}
+
func TestCacheInvalidatesOnSpecHashMismatch(t *testing.T) {
redirectCache(t)
spec := sampleSpec()
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index d627ee5c6c..c7c9143a50 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -22,6 +22,7 @@ import (
"time"
"reasonix/internal/event"
+ "reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -554,6 +555,7 @@ type ToolInfo struct {
Name string
Description string
ReadOnlyHint bool
+ SchemaError string
}
// ServerStatus summarises one connected server for the /mcp command.
@@ -1169,18 +1171,25 @@ func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
tools := make([]tool.Tool, 0, len(out))
for _, t := range out {
hinted := t.Annotations != nil && t.Annotations.ReadOnlyHint
+ info := ToolInfo{Name: t.Name, Description: t.Description, ReadOnlyHint: hinted}
+ schema, err := normalizeAndValidateToolSchema(t.InputSchema)
+ if err != nil {
+ info.SchemaError = schemaValidationError(err)
+ toolInfos = append(toolInfos, info)
+ continue
+ }
visibleName := t.Name
if c.spec.StripRawPrefix != "" {
visibleName = strings.TrimPrefix(visibleName, c.spec.StripRawPrefix)
}
- toolInfos = append(toolInfos, ToolInfo{Name: t.Name, Description: t.Description, ReadOnlyHint: hinted})
+ toolInfos = append(toolInfos, info)
trusted := c.spec.toolReadOnlyTrusted(t.Name, visibleName)
tools = append(tools, &remoteTool{
client: c,
name: toolName(c.name, visibleName),
rawName: t.Name,
desc: t.Description,
- schema: canonicalizeSchema(t.InputSchema),
+ schema: schema,
readOnly: c.spec.toolReadOnly(t.Name, visibleName, hinted),
readOnlyTrusted: trusted,
})
@@ -1193,6 +1202,24 @@ func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
return append([]tool.Tool(nil), sortedTools...), nil
}
+func normalizeAndValidateToolSchema(raw json.RawMessage) (json.RawMessage, error) {
+ schema := canonicalizeSchema(raw)
+ if err := provider.ValidateToolSchema(schema); err != nil {
+ return nil, err
+ }
+ return schema, nil
+}
+
+func schemaValidationError(err error) string {
+ const maxRunes = 512
+ msg := strings.TrimSpace(err.Error())
+ runes := []rune(msg)
+ if len(runes) > maxRunes {
+ msg = string(runes[:maxRunes]) + "..."
+ }
+ return "invalid input schema: " + msg
+}
+
func (c *Client) listToolsRaw(ctx context.Context) ([]mcpTool, error) {
res, err := c.call(ctx, "tools/list", map[string]any{})
if err != nil {
diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go
index 2026ad4452..03bebc9139 100644
--- a/internal/plugin/plugin_test.go
+++ b/internal/plugin/plugin_test.go
@@ -365,6 +365,58 @@ func TestClientListToolsRetriesAdvertisedEmptyToolList(t *testing.T) {
}
}
+func TestClientListToolsQuarantinesMalformedSchema(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ tr := &countingToolsTransport{raw: json.RawMessage(`{
+ "tools":[
+ {"name":"echo","description":"Still available.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}}}},
+ {"name":"generate_yso_bytes","description":"Broken nested schema.","inputSchema":{"type":"object","properties":{"options":{"type":"array","items":{"key":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}}}}}}
+ ]
+ }`)}
+ c := &Client{name: "yakit", t: tr, spec: Spec{Name: "yakit"}, transport: "stdio"}
+
+ tools, err := c.listTools(ctx)
+ if err != nil {
+ t.Fatalf("listTools: %v", err)
+ }
+ if len(tools) != 1 || tools[0].Name() != "mcp__yakit__echo" {
+ t.Fatalf("tools = %v, want only mcp__yakit__echo", names(tools))
+ }
+ if got := string(tools[0].Schema()); got != `{"properties":{"msg":{"type":"string"}},"type":"object"}` {
+ t.Fatalf("valid sibling schema changed: %s", got)
+ }
+ if len(c.tools) != 2 {
+ t.Fatalf("tool status count = %d, want both advertised tools", len(c.tools))
+ }
+ if c.tools[0].Name != "echo" || c.tools[0].SchemaError != "" {
+ t.Fatalf("valid tool status = %+v", c.tools[0])
+ }
+ if c.tools[1].Name != "generate_yso_bytes" || !strings.Contains(c.tools[1].SchemaError, "/properties/options/items/type") {
+ t.Fatalf("quarantined tool status = %+v", c.tools[1])
+ }
+}
+
+func TestClientListToolsValidatesAfterCompatibilityNormalization(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ tr := &countingToolsTransport{raw: json.RawMessage(`{"tools":[{"name":"legacy","inputSchema":{"type":"object","properties":{"query":{"type":"string","required":true}}}}]}`)}
+ c := &Client{name: "legacy", t: tr, spec: Spec{Name: "legacy"}, transport: "stdio"}
+
+ tools, err := c.listTools(ctx)
+ if err != nil {
+ t.Fatalf("listTools: %v", err)
+ }
+ if len(tools) != 1 {
+ t.Fatalf("tools = %v, want normalized legacy tool", names(tools))
+ }
+ if got := string(tools[0].Schema()); got != `{"properties":{"query":{"type":"string"}},"type":"object"}` {
+ t.Fatalf("normalized schema = %s", got)
+ }
+}
+
func TestSpecReadOnlyToolNamesMarksUnhintedToolsReadOnly(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
diff --git a/internal/provider/schema_canonicalize.go b/internal/provider/schema_canonicalize.go
index ae745a188d..20e697d09f 100644
--- a/internal/provider/schema_canonicalize.go
+++ b/internal/provider/schema_canonicalize.go
@@ -19,6 +19,12 @@ func CanonicalizeSchema(raw json.RawMessage) json.RawMessage {
if err := json.Unmarshal(raw, &v); err != nil {
return raw
}
+ if v == nil {
+ // A nil RawMessage persists as JSON null in the MCP schema cache. Treat
+ // both forms as the same no-argument schema so old cache entries remain
+ // usable and never reach a strict provider as parameters: null.
+ return json.RawMessage(`{"properties":{},"type":"object"}`)
+ }
canon := canonicalizeSchemaValue(v)
ensureRootObjectProperties(canon)
b, err := json.Marshal(canon)
diff --git a/internal/provider/schema_canonicalize_test.go b/internal/provider/schema_canonicalize_test.go
index bf76344231..ff3a5e4e47 100644
--- a/internal/provider/schema_canonicalize_test.go
+++ b/internal/provider/schema_canonicalize_test.go
@@ -25,6 +25,7 @@ func TestCanonicalizeSchemaDropsNonArrayRequired(t *testing.T) {
func TestCanonicalizeSchemaAddsEmptyPropertiesForNoArgumentObject(t *testing.T) {
for _, raw := range []json.RawMessage{
nil,
+ json.RawMessage(`null`),
json.RawMessage(`{"type":"object"}`),
} {
got := string(CanonicalizeSchema(raw))
diff --git a/internal/provider/schema_validate.go b/internal/provider/schema_validate.go
new file mode 100644
index 0000000000..f7f45adff7
--- /dev/null
+++ b/internal/provider/schema_validate.go
@@ -0,0 +1,43 @@
+package provider
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+
+ jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
+)
+
+const toolSchemaResource = "urn:reasonix:tool-schema"
+
+// ValidateToolSchema compiles a provider-visible tool parameter schema without
+// resolving external resources. MCP schemas default to draft-07 when they do
+// not declare a dialect; explicit $schema declarations still take precedence.
+func ValidateToolSchema(raw json.RawMessage) error {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ var doc any
+ if err := decoder.Decode(&doc); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("invalid JSON: multiple values")
+ }
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ if _, ok := doc.(map[string]any); !ok {
+ return fmt.Errorf("root must be an object")
+ }
+
+ compiler := jsonschema.NewCompiler()
+ compiler.DefaultDraft(jsonschema.Draft7)
+ if err := compiler.AddResource(toolSchemaResource, doc); err != nil {
+ return fmt.Errorf("load schema: %w", err)
+ }
+ if _, err := compiler.Compile(toolSchemaResource); err != nil {
+ return fmt.Errorf("compile schema: %w", err)
+ }
+ return nil
+}
diff --git a/internal/provider/schema_validate_test.go b/internal/provider/schema_validate_test.go
new file mode 100644
index 0000000000..12b086ba31
--- /dev/null
+++ b/internal/provider/schema_validate_test.go
@@ -0,0 +1,54 @@
+package provider
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestValidateToolSchemaAcceptsValidDrafts(t *testing.T) {
+ for _, raw := range []json.RawMessage{
+ json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}}}`),
+ json.RawMessage(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"values":{"type":"array","prefixItems":[{"type":"string"}]}}}`),
+ } {
+ if err := ValidateToolSchema(raw); err != nil {
+ t.Fatalf("ValidateToolSchema(%s): %v", raw, err)
+ }
+ }
+}
+
+func TestValidateToolSchemaRejectsMalformedNestedArrayItems(t *testing.T) {
+ raw := json.RawMessage(`{
+ "type":"object",
+ "properties":{
+ "options":{
+ "type":"array",
+ "items":{
+ "key":{"description":"option key","type":"string"},
+ "type":{"description":"option type","type":"string"},
+ "value":{"description":"option value","type":"string"}
+ }
+ }
+ }
+ }`)
+ err := ValidateToolSchema(raw)
+ if err == nil {
+ t.Fatal("malformed Yakit-style array item schema was accepted")
+ }
+ if msg := err.Error(); !strings.Contains(msg, "/properties/options/items/type") {
+ t.Fatalf("error does not identify the malformed nested type: %v", err)
+ }
+}
+
+func TestValidateToolSchemaRejectsNonObjectRootAndExternalRefs(t *testing.T) {
+ for name, raw := range map[string]json.RawMessage{
+ "array root": json.RawMessage(`[]`),
+ "external ref": json.RawMessage(`{"type":"object","properties":{"x":{"$ref":"https://example.com/schema.json"}}}`),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := ValidateToolSchema(raw); err == nil {
+ t.Fatalf("ValidateToolSchema(%s) returned nil", raw)
+ }
+ })
+ }
+}