Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
43 changes: 33 additions & 10 deletions internal/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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",
Expand All @@ -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
}`

Expand Down Expand Up @@ -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)
Expand Down
79 changes: 73 additions & 6 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down
9 changes: 2 additions & 7 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"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"
},
"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"
},
Expand All @@ -35,11 +35,6 @@
"isRequired": true
}
}
},
{
"type": "named",
"name": "-w",
"value": "/workspace"
}
],
"packageArguments": [
Expand Down
Loading