fix(mcp): quarantine tools with invalid schemas

This commit is contained in:
SivanCola committed 2026-07-14 12:17:57 +08:00
1 parent 1ec5cbe1be
commit ed113b4909
24 files changed
+341 -11

No files matched your search

+4 -1
View File
@@ -5826,6 +5826,7 @@ type ToolView struct {
Name string `json:"name"`
Description string `json:"description"`
ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
SchemaError string `json:"schemaError,omitempty"`
}
// SkillView is one discoverable skill for the drawer. Also backs the
@@ -7203,7 +7204,9 @@ func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
}
out := make([]ToolView, 0, len(tools))
for _, t := range tools {
out = append(out, ToolView{Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint})
out = append(out, ToolView{
Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint, SchemaError: t.SchemaError,
})
}
return out
}
+10
View File
@@ -36,6 +36,16 @@ import (
"reasonix/internal/tool"
)
func TestPluginToolsToViewPreservesSchemaError(t *testing.T) {
got := pluginToolsToView([]plugin.ToolInfo{{
Name: "generate_yso_bytes", Description: "Generate payload", ReadOnlyHint: true,
SchemaError: "invalid input schema: bad nested type",
}})
if len(got) != 1 || got[0].SchemaError != "invalid input schema: bad nested type" {
t.Fatalf("tool views = %+v", got)
}
}
func desktopMCPHTTPServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -207,6 +207,7 @@ console.log("capabilities panel MCP actions");
toolList: [
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
{ name: "issue_write", description: "Write issues." },
{ name: "broken_read", description: "Broken tool.", readOnlyHint: true, schemaError: "invalid input schema: bad nested type" },
],
trustedReadOnlyTools: [],
}];
@@ -269,6 +270,9 @@ console.log("capabilities panel MCP actions");
});
await waitFor("trusted badge", () => Boolean(document.querySelector(".cap-tool-trust")?.textContent?.includes("Trusted")));
await waitFor("unavailable tool", () => Boolean(document.querySelector(".cap-tool-hint--error")?.textContent?.includes("Unavailable")));
ok(document.body.textContent?.includes("1 unavailable"), "server summary reports one quarantined tool");
ok(document.body.textContent?.includes("invalid input schema: bad nested type"), "tool list shows the schema diagnostic");
const untrust = findButton("Untrust");
if (!untrust) throw new Error("missing Untrust button");
await act(async () => {
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ShieldCheck, ShieldOff } from "lucide-react";
import { CircleAlert, ShieldCheck, ShieldOff } from "lucide-react";
import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
@@ -894,6 +894,7 @@ function ServerRow({
const actionLabel = serverActionLabel(s, t);
const lifecycle = mcpServerLifecycleActions(s);
const tools = s.toolList ?? [];
const schemaIssueCount = tools.filter((tool) => tool.schemaError).length;
let sub =
s.status === "failed"
? s.error || t("caps.failed")
@@ -906,6 +907,9 @@ function ServerRow({
? t("caps.disabledAutoStart")
: t("caps.disabled")
: t("caps.counts", { tools: s.tools, prompts: s.prompts, resources: s.resources });
if (schemaIssueCount > 0) {
sub = `${sub} · ${t("caps.schemaIssues", { count: schemaIssueCount })}`;
}
if (s.managedByPlugin) {
sub = `${sub} · ${t("caps.managedByPlugin", { plugin: s.managedByPlugin })}`;
}
@@ -1034,7 +1038,7 @@ function ServerDetails({
const trustedReadOnlyTools = s.trustedReadOnlyTools ?? [];
const trustedReadOnlyToolNames = new Set(trustedReadOnlyTools);
const canTrustTool = canMutateConfig;
const reportedReadOnlyToolNames = (tools ?? []).filter((tool) => tool.readOnlyHint).map((tool) => tool.name);
const reportedReadOnlyToolNames = (tools ?? []).filter((tool) => tool.readOnlyHint && !tool.schemaError).map((tool) => tool.name);
const bulkTrustToolNames = reportedReadOnlyToolNames.filter((name) => !trustedReadOnlyToolNames.has(name));
if (editing && canEditConfig) {
return (
@@ -1144,19 +1148,25 @@ function ServerDetails({
<div className="cap-tool-list__title">{t("caps.tools")}</div>
{tools.map((tool) => {
const trusted = trustedReadOnlyToolNames.has(tool.name);
const unavailable = Boolean(tool.schemaError);
return (
<div className="cap-tool" key={tool.name}>
<div className={`cap-tool${unavailable ? " cap-tool--unavailable" : ""}`} key={tool.name}>
<div className="cap-tool__name">{tool.name}</div>
<div className="cap-tool__desc">
<span>{tool.description}</span>
{tool.readOnlyHint && (
<span>{unavailable ? tool.schemaError : tool.description}</span>
{unavailable ? (
<span className="cap-tool-hint cap-tool-hint--error" title={tool.schemaError}>
<CircleAlert aria-hidden size={11} strokeWidth={2.2} />
{t("caps.toolUnavailable")}
</span>
) : tool.readOnlyHint ? (
<span className="cap-tool-hint" title={t("caps.reportedReadOnlyTitle")}>
{t("caps.reportedReadOnly")}
</span>
)}
) : null}
</div>
<div className="cap-tool__action">
{canTrustTool ? (
{!unavailable && canTrustTool ? (
trusted ? (
<div className="cap-tool-trust-stack">
<span className="cap-tool-trust cap-tool-trust--trusted" title={t("caps.trustedReadOnlyTitle")}>
+1
View File
@@ -696,6 +696,7 @@ export interface MCPToolView {
name: string;
description: string;
readOnlyHint?: boolean;
schemaError?: string;
}
export interface SkillView {
name: string;
+2
View File
@@ -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",
+2
View File
@@ -150,6 +150,7 @@ export const zhTW: Record<DictKey, string> = {
"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<DictKey, string> = {
"caps.expandTools": "展開工具列表",
"caps.collapseTools": "收起工具列表",
"caps.noToolDetails": "當前連線未返回工具明細。",
"caps.toolUnavailable": "不可用",
"caps.trustReadOnly": "預先信任",
"caps.trustedReadOnly": "已信任",
"caps.trustedReadOnlyTools": "已記住唯讀",
+2
View File
@@ -235,6 +235,7 @@ export const zh: Record<DictKey, string> = {
"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<DictKey, string> = {
"caps.expandTools": "展开工具列表",
"caps.collapseTools": "收起工具列表",
"caps.noToolDetails": "当前连接未返回工具明细。",
"caps.toolUnavailable": "不可用",
"caps.trustReadOnly": "预先信任",
"caps.trustedReadOnly": "已信任",
"caps.trustedReadOnlyTools": "已记住只读",
+10
View File
@@ -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;
+1
View File
@@ -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
+4
View File
@@ -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=
+1
View File
@@ -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
+4
View File
@@ -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=
+5 -1
View File
@@ -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 {
+20
View File
@@ -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)
+27
View File
@@ -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) {
+14
View File
@@ -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
+28
View File
@@ -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()
+29 -2
View File
@@ -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 {
+52
View File
@@ -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()
+6
View File
@@ -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)
@@ -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))
+43
View File
@@ -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
}
+54
View File
@@ -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)
}
})
}
}