From 032eb3332940ce9fb7d87bfa0e7b7cbc0a256a4d Mon Sep 17 00:00:00 2001 From: sairenchristianbuerano Date: Fri, 25 Sep 2026 08:00:18 +0800 Subject: [PATCH 1/2] fix(mcp): default scan path to the working directory and advertise URL scanning The scan tool required a path described only as a local directory. Run from the published container that combination fails: the client sends a path from its own machine, which does not exist inside the container, so the first scan a new user attempts errors out. Make path optional, defaulting to the server's working directory, which the container image already sets to the mounted repository. Also name the GitHub URL form in the description - ingestion has always resolved remotes, but the schema never said so, leaving the capability undiscoverable to a model. --- internal/mcpserver/server.go | 34 +++++++++++++------ internal/mcpserver/server_test.go | 56 +++++++++++++++++++++++++++---- server.json | 4 +-- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 864df13e..7bb848a5 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "github.com/trustabl/trustabl/internal/models" ) @@ -14,11 +15,15 @@ import ( // 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 +163,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. Defaults to the server's working directory when omitted — use the default when the server runs in a container, since a path from the calling machine does not exist inside it." }, "rules_ref": { "type": "string", @@ -176,7 +183,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 +223,15 @@ 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)) + // An omitted path means "scan where the server is running". In the + // container package that is the mounted repository (the image is run + // with -w on it), which is the only path the server can actually see — + // the caller's own path does not exist inside the container. + wd, err := os.Getwd() + if err != nil { + return c.writeResult(id, textResult(fmt.Sprintf("scan: no path given and the working directory is unavailable: %v", err), true)) + } + sr.Path = wd } result, err := s.scan(ctx, sr) diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 34d1fb6f..388b3ad3 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -193,10 +193,18 @@ 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"}) +// TestScanTool_OmittedPathDefaultsToWorkingDirectory pins the behaviour the +// container package depends on. The image is run with -w on the mounted repo, +// so "no path" has to mean "the directory I am running in". A caller's own path +// does not exist inside the container, which is why the schema no longer +// requires the field. +func TestScanTool_OmittedPathDefaultsToWorkingDirectory(t *testing.T) { + 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 +221,44 @@ 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") + if tr.IsError { + t.Fatalf("omitted path should not error: %s", resps[0].Result) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if got.Path != wd { + t.Errorf("path = %q, want the working directory %q", got.Path, wd) + } +} + +// 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..30f3915d 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" }, From b287f7192fcefebb482334206e6e79fe9dd4aed4 Mon Sep 17 00:00:00 2001 From: sairenchristianbuerano Date: Fri, 25 Sep 2026 08:05:37 +0800 Subject: [PATCH 2/2] fix(mcp): scope the omitted-path default to the container image The first version of this change defaulted an omitted path to the process working directory. That is wrong outside a container: MCP clients choose the working directory, and Cursor launches servers from the user's home, so an omitted path would have walked everything they own. Default only where the directory is known to be right. The image sets WORKDIR and TRUSTABL_MCP_DEFAULT_PATH to the mount point; nothing else sets it, and without it the server asks for a path instead of guessing. The error names the GitHub URL form, which ingestion has always accepted but the schema never advertised. Drops -w from the install config, since WORKDIR now carries it. --- Dockerfile | 5 ++++ internal/mcpserver/server.go | 27 +++++++++++------ internal/mcpserver/server_test.go | 49 +++++++++++++++++++++++-------- server.json | 5 ---- 4 files changed, 59 insertions(+), 27 deletions(-) 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 7bb848a5..821c7ad3 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -10,6 +10,13 @@ import ( "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. @@ -172,7 +179,7 @@ const scanInputSchema = `{ "properties": { "path": { "type": "string", - "description": "What to scan: a local directory, or a GitHub repository URL such as https://github.com/owner/repo, which is cloned and scanned. Defaults to the server's working directory when omitted — use the default when the server runs in a container, since a path from the calling machine does not exist inside it." + "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", @@ -223,15 +230,17 @@ func (s *Server) callScan(ctx context.Context, c *conn, id json.RawMessage, args } } if sr.Path == "" { - // An omitted path means "scan where the server is running". In the - // container package that is the mounted repository (the image is run - // with -w on it), which is the only path the server can actually see — - // the caller's own path does not exist inside the container. - wd, err := os.Getwd() - if err != nil { - return c.writeResult(id, textResult(fmt.Sprintf("scan: no path given and the working directory is unavailable: %v", err), 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 = wd + sr.Path = def } result, err := s.scan(ctx, sr) diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 388b3ad3..727f9a5f 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -193,12 +193,10 @@ func TestScanTool_VulnScanArg(t *testing.T) { } } -// TestScanTool_OmittedPathDefaultsToWorkingDirectory pins the behaviour the -// container package depends on. The image is run with -w on the mounted repo, -// so "no path" has to mean "the directory I am running in". A caller's own path -// does not exist inside the container, which is why the schema no longer -// requires the field. -func TestScanTool_OmittedPathDefaultsToWorkingDirectory(t *testing.T) { +// 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 @@ -221,16 +219,41 @@ func TestScanTool_OmittedPathDefaultsToWorkingDirectory(t *testing.T) { if err := json.Unmarshal(resps[0].Result, &tr); err != nil { t.Fatal(err) } - if tr.IsError { - t.Fatalf("omitted path should not error: %s", resps[0].Result) + 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) } +} - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) +// 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 got.Path != wd { - t.Errorf("path = %q, want the working directory %q", got.Path, wd) + if !strings.Contains(string(raw), "github.com") { + t.Errorf("error should point at the URL form, got: %s", raw) } } diff --git a/server.json b/server.json index 30f3915d..b6827d93 100644 --- a/server.json +++ b/server.json @@ -35,11 +35,6 @@ "isRequired": true } } - }, - { - "type": "named", - "name": "-w", - "value": "/workspace" } ], "packageArguments": [