diff --git a/Dockerfile b/Dockerfile index 5be417b3..79bc3d05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,9 @@ COPY $TARGETPLATFORM/trustabl /usr/local/bin/trustabl # image and matching it against `name` in server.json. Without it, publishing # fails with "Registry validation failed for package". Keep the two in sync. LABEL io.modelcontextprotocol.server.name="io.github.trustabl/agent-reliability-analyzer" +# Where callers mount the repository, and what `trustabl mcp` scans when a +# client sends no path. Only the image sets this: outside a container there is +# no safe default, since MCP clients choose the working directory themselves. +WORKDIR /workspace +ENV TRUSTABL_MCP_DEFAULT_PATH=/workspace ENTRYPOINT ["/usr/local/bin/trustabl"] diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 864df13e..821c7ad3 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -5,20 +5,32 @@ import ( "encoding/json" "fmt" "io" + "os" "github.com/trustabl/trustabl/internal/models" ) +// defaultPathEnv names the directory to scan when a client sends no path. The +// container image sets it to the mounted repository; nothing else does. It is +// deliberately opt-in rather than a working-directory fallback, because an MCP +// client picks the working directory and some launch servers from the user's +// home, where a default would scan far more than anyone asked for. +const defaultPathEnv = "TRUSTABL_MCP_DEFAULT_PATH" + // protocolVersion is the MCP protocol revision this server implements. Clients // send their own version in initialize; we echo a version we support. This is // the stable revision the stdio tool surface targets. const protocolVersion = "2024-11-05" -// ScanRequest is the input schema for the `scan` tool. Path is required and -// names a local directory or repository to scan; RulesRef optionally pins the -// detection-rules branch or tag (mirrors the CLI's --rules-ref). VulnScan opts -// the call into OSV dependency-vulnerability matching (mirrors --vuln-scan): off -// by default, so a scan stays offline-capable and fast unless the client asks. +// ScanRequest is the input schema for the `scan` tool. Path names a local +// directory or repository to scan; when empty it defaults to the server's own +// working directory. That default is what makes the container package usable: +// the image is run with -w on the mounted repo, and a client that sends the +// host's path would otherwise ask the server to stat a path that does not +// exist inside the container. RulesRef optionally pins the detection-rules +// branch or tag (mirrors the CLI's --rules-ref). VulnScan opts the call into +// OSV dependency-vulnerability matching (mirrors --vuln-scan): off by default, +// so a scan stays offline-capable and fast unless the client asks. type ScanRequest struct { Path string `json:"path"` RulesRef string `json:"rules_ref,omitempty"` @@ -158,14 +170,16 @@ func (s *Server) toolsListResult() map[string]any { } } -// scanInputSchema is the JSON Schema for the `scan` tool input. path is -// required; rules_ref and vuln_scan are optional. +// scanInputSchema is the JSON Schema for the `scan` tool input. Every field is +// optional: omitting path scans the server's working directory. The path +// description names the GitHub-URL form explicitly, because a model only offers +// what the schema advertises — the capability existed before and went unused. const scanInputSchema = `{ "type": "object", "properties": { "path": { "type": "string", - "description": "Local directory or repository path to scan." + "description": "What to scan: a local directory, or a GitHub repository URL such as https://github.com/owner/repo, which is cloned and scanned. When this server runs as a container it scans the repository mounted into it, so omit this field rather than sending a path from your own machine, which does not exist inside the container." }, "rules_ref": { "type": "string", @@ -176,7 +190,6 @@ const scanInputSchema = `{ "description": "Match declared dependencies against a pinned OSV snapshot and report known CVEs in 'vulnerabilities' and as findings (default false; fetches the OSV database on first use, then reuses the cache)." } }, - "required": ["path"], "additionalProperties": false }` @@ -217,7 +230,17 @@ func (s *Server) callScan(ctx context.Context, c *conn, id json.RawMessage, args } } if sr.Path == "" { - return c.writeResult(id, textResult("scan: 'path' is required", true)) + // Only the container image declares a default, via defaultPathEnv. Do + // NOT fall back to the process working directory here: MCP clients + // choose that themselves and some launch servers from the user's home + // directory, so an omitted path would silently walk everything they own. + // Outside the image there is no safe guess, so say what to send instead. + def := os.Getenv(defaultPathEnv) + if def == "" { + return c.writeResult(id, textResult( + "scan: no 'path' given. Send a local directory, or a GitHub repository URL such as https://github.com/owner/repo.", true)) + } + sr.Path = def } result, err := s.scan(ctx, sr) diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 34d1fb6f..727f9a5f 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -193,10 +193,16 @@ func TestScanTool_VulnScanArg(t *testing.T) { } } -// TestScanTool_MissingPath returns an isError tool result, not a protocol -// error: the model should see a usable message. -func TestScanTool_MissingPath(t *testing.T) { - srv := New(fixtureScan(t), VersionInfo{Version: "test"}) +// scanOmittingPath calls the scan tool with no arguments and returns the +// ScanRequest the server passed through, plus whether the call was an error. +func scanOmittingPath(t *testing.T) (ScanRequest, bool, json.RawMessage) { + t.Helper() + var got ScanRequest + srv := New(func(_ context.Context, req ScanRequest) (models.ScanResult, error) { + got = req + return models.ScanResult{ScanID: "x"}, nil + }, VersionInfo{Version: "test"}) + call := mustJSON(t, map[string]any{ "jsonrpc": "2.0", "id": 7, @@ -213,8 +219,69 @@ func TestScanTool_MissingPath(t *testing.T) { if err := json.Unmarshal(resps[0].Result, &tr); err != nil { t.Fatal(err) } - if !tr.IsError { - t.Error("missing path should produce isError=true") + return got, tr.IsError, resps[0].Result +} + +// TestScanTool_OmittedPathUsesContainerDefault covers the container package: +// the image sets TRUSTABL_MCP_DEFAULT_PATH to the mounted repository, so a +// client that sends no path scans that rather than failing. +func TestScanTool_OmittedPathUsesContainerDefault(t *testing.T) { + t.Setenv(defaultPathEnv, "/workspace") + + got, isErr, raw := scanOmittingPath(t) + if isErr { + t.Fatalf("omitted path should use the configured default, got error: %s", raw) + } + if got.Path != "/workspace" { + t.Errorf("path = %q, want /workspace", got.Path) + } +} + +// TestScanTool_OmittedPathWithoutDefaultAsksForOne is the other half, and the +// more important one. Outside the image there is NO default: MCP clients pick +// the working directory and some launch servers from the user's home, so +// falling back to it would silently walk everything they own. The error names +// the URL form so the model has something useful to send next. +func TestScanTool_OmittedPathWithoutDefaultAsksForOne(t *testing.T) { + t.Setenv(defaultPathEnv, "") + + got, isErr, raw := scanOmittingPath(t) + if !isErr { + t.Fatalf("omitted path with no configured default must error rather than guess, got: %s", raw) + } + if got.Path != "" { + t.Errorf("scan must not have run, but got path %q", got.Path) + } + if !strings.Contains(string(raw), "github.com") { + t.Errorf("error should point at the URL form, got: %s", raw) + } +} + +// TestScanSchema_PathOptionalAndAdvertisesURL guards the two halves of the +// contract a client reads: path must not be required (or a model is forced to +// invent one), and the description must name the GitHub-URL form (or the +// capability stays invisible, which is exactly what happened before). +func TestScanSchema_PathOptionalAndAdvertisesURL(t *testing.T) { + var schema struct { + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal([]byte(scanInputSchema), &schema); err != nil { + t.Fatalf("scanInputSchema invalid: %v", err) + } + for _, r := range schema.Required { + if r == "path" { + t.Error("path must not be required: an omitted path scans the working directory") + } + } + var p struct { + Description string `json:"description"` + } + if err := json.Unmarshal(schema.Properties["path"], &p); err != nil { + t.Fatal(err) + } + if !strings.Contains(p.Description, "github.com") { + t.Errorf("path description must advertise the GitHub URL form, got: %q", p.Description) } } diff --git a/server.json b/server.json index d8faae53..b6827d93 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.trustabl/agent-reliability-analyzer", "title": "Trustabl", "description": "Find and fix reliability and safety gaps in agent code, across nine agent SDKs.", - "version": "0.1.11", + "version": "0.1.12", "repository": { "url": "https://github.com/trustabl/agent-reliability-analyzer", "source": "github" @@ -11,7 +11,7 @@ "packages": [ { "registryType": "oci", - "identifier": "ghcr.io/trustabl/agent-reliability-analyzer:0.1.11", + "identifier": "ghcr.io/trustabl/agent-reliability-analyzer:0.1.12", "transport": { "type": "stdio" }, @@ -35,11 +35,6 @@ "isRequired": true } } - }, - { - "type": "named", - "name": "-w", - "value": "/workspace" } ], "packageArguments": [