diff --git a/cmd/dockhand/main.go b/cmd/dockhand/main.go index 7c62fed4..d20efca6 100644 --- a/cmd/dockhand/main.go +++ b/cmd/dockhand/main.go @@ -3,6 +3,7 @@ package main import ( "context" + "encoding/json" "fmt" "log/slog" "os" @@ -22,6 +23,17 @@ import ( skillpkg "github.com/stacklok/dockyard/internal/skills" ) +// Supported package protocols. +const ( + protocolNpx = "npx" + protocolUvx = "uvx" + protocolGo = "go" + + // mcpContainerVersion is the placeholder version toolhive's npx template stamps into + // the generated package.json; we reuse it when re-emitting that file with overrides. + mcpContainerVersion = "1.0.0" +) + // MCPServerSpec defines the structure of our YAML configuration files type MCPServerSpec struct { // Metadata about the MCP server @@ -44,6 +56,33 @@ type MCPServerPackageSpec struct { Package string `yaml:"package"` // e.g., "@upstash/context7-mcp" Version string `yaml:"version,omitempty"` // e.g., "1.0.14" Args []string `yaml:"args,omitempty"` // Additional arguments for the package + + // Overrides forces specific versions of transitive npm dependencies (npx protocol). + // Each entry is injected into an "overrides" block of the generated package.json so + // that npm resolves the pinned version regardless of upstream's declared range. + Overrides []OverrideEntry `yaml:"overrides,omitempty"` + + // Constraints forces specific versions of transitive Python dependencies (uvx protocol). + // Each entry is written to a uv overrides requirements file and passed to + // "uv tool install --overrides" so that uv resolves the pinned version even when + // upstream caps the dependency. + Constraints []ConstraintEntry `yaml:"constraints,omitempty"` +} + +// OverrideEntry pins a transitive npm dependency to a specific version (npx protocol). +// Reason is mandatory so the justification for circumventing the upstream pin is auditable +// in-repo, mirroring security.allowed_issues. +type OverrideEntry struct { + Package string `yaml:"package"` // e.g., "@modelcontextprotocol/sdk" + Version string `yaml:"version"` // e.g., "1.26.0" + Reason string `yaml:"reason"` // why this override is needed (required) +} + +// ConstraintEntry pins a transitive Python dependency via a uv override requirement +// (uvx protocol). Reason is mandatory so the justification is auditable in-repo. +type ConstraintEntry struct { + Spec string `yaml:"spec"` // a PEP 508 requirement, e.g., "fastmcp>=3.2.0" + Reason string `yaml:"reason"` // why this constraint is needed (required) } // MCPServerProvenance contains supply chain provenance information @@ -335,7 +374,7 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) { } // Validate protocol - validProtocols := []string{"npx", "uvx", "go"} + validProtocols := []string{protocolNpx, protocolUvx, protocolGo} isValid := false for _, p := range validProtocols { if spec.Metadata.Protocol == p { @@ -347,9 +386,49 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) { return nil, fmt.Errorf("invalid protocol %s, must be one of: %v", spec.Metadata.Protocol, validProtocols) } + // Validate dependency overrides/constraints + if err := validateDependencyOverrides(&spec); err != nil { + return nil, err + } + return &spec, nil } +// validateDependencyOverrides validates the optional overrides (npx) and constraints +// (uvx) blocks. Every entry must carry a non-empty Reason so the justification for +// circumventing an upstream version pin is auditable in-repo. +func validateDependencyOverrides(spec *MCPServerSpec) error { + if len(spec.Spec.Overrides) > 0 && spec.Metadata.Protocol != protocolNpx { + return fmt.Errorf("spec.overrides is only supported for the npx protocol, got %q", spec.Metadata.Protocol) + } + if len(spec.Spec.Constraints) > 0 && spec.Metadata.Protocol != protocolUvx { + return fmt.Errorf("spec.constraints is only supported for the uvx protocol, got %q", spec.Metadata.Protocol) + } + + for i, o := range spec.Spec.Overrides { + if o.Package == "" { + return fmt.Errorf("spec.overrides[%d].package is required", i) + } + if o.Version == "" { + return fmt.Errorf("spec.overrides[%d].version is required", i) + } + if strings.TrimSpace(o.Reason) == "" { + return fmt.Errorf("spec.overrides[%d].reason is required (document why %s is pinned to %s)", i, o.Package, o.Version) + } + } + + for i, c := range spec.Spec.Constraints { + if strings.TrimSpace(c.Spec) == "" { + return fmt.Errorf("spec.constraints[%d].spec is required", i) + } + if strings.TrimSpace(c.Reason) == "" { + return fmt.Errorf("spec.constraints[%d].reason is required (document why %q is constrained)", i, c.Spec) + } + } + + return nil +} + // generateDockerfile generates a Dockerfile using toolhive's library func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag string) (string, error) { // Create the protocol scheme string @@ -383,9 +462,159 @@ func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag stri return "", fmt.Errorf("failed to generate Dockerfile for protocol scheme %s: %w", protocolScheme, err) } + // Post-process the generated Dockerfile to inject any dependency overrides. + // toolhive returns the Dockerfile as a string, which is our injection seam; toolhive + // itself needs no changes. + dockerfile, err = injectDependencyOverrides(dockerfile, spec) + if err != nil { + return "", fmt.Errorf("failed to inject dependency overrides: %w", err) + } + return dockerfile, nil } +// injectDependencyOverrides rewrites the generated Dockerfile to force pinned versions +// of transitive dependencies. For npx it injects an npm "overrides" block; for uvx it +// adds a uv overrides requirements file to the "uv tool install" step. It matches the +// relevant install step by content (not line number) so it stays robust to changes in +// toolhive's template formatting. +func injectDependencyOverrides(dockerfile string, spec *MCPServerSpec) (string, error) { + switch spec.Metadata.Protocol { + case protocolNpx: + if len(spec.Spec.Overrides) == 0 { + return dockerfile, nil + } + return injectNpmOverrides(dockerfile, spec.Spec.Overrides) + case protocolUvx: + if len(spec.Spec.Constraints) == 0 { + return dockerfile, nil + } + return injectUvOverrides(dockerfile, spec.Spec.Constraints) + default: + return dockerfile, nil + } +} + +// injectNpmOverrides rewrites the package.json creation step so the generated package.json +// carries an "overrides" block. npm honors "overrides" only when present in the package.json +// it installs into, so this is injected before the "npm install" step. The toolhive template +// creates the package.json with a line of the form: +// +// RUN echo '{"name":"mcp-container","version":"1.0.0"}' > package.json +// +// We locate that line by content (the "> package.json" redirect) and replace the JSON payload +// with one that includes the overrides. +func injectNpmOverrides(dockerfile string, overrides []OverrideEntry) (string, error) { + overrideMap := make(map[string]string, len(overrides)) + for _, o := range overrides { + overrideMap[o.Package] = o.Version + } + + // Mirror the package.json name/version that toolhive's npx template emits, adding the + // overrides block. + pkgJSON := map[string]any{ + "name": "mcp-container", + "version": mcpContainerVersion, + "overrides": overrideMap, + } + pkgJSONBytes, err := json.Marshal(pkgJSON) + if err != nil { + return "", fmt.Errorf("failed to marshal package.json with overrides: %w", err) + } + + lines := strings.Split(dockerfile, "\n") + injected := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + // Match the package.json creation step regardless of the exact JSON payload. + if strings.HasPrefix(trimmed, "RUN echo '") && strings.Contains(trimmed, "> package.json") { + lines[i] = fmt.Sprintf("RUN echo '%s' > package.json", string(pkgJSONBytes)) + injected = true + break + } + } + + if !injected { + return "", fmt.Errorf("could not find the 'package.json' creation step in the generated Dockerfile to inject npm overrides") + } + + return strings.Join(lines, "\n"), nil +} + +// injectUvOverrides rewrites the "uv tool install" step so it passes a uv overrides +// requirements file. uv honors override requirements via "--overrides ", forcing the +// resolved version of a transitive dependency even when upstream caps it. The toolhive +// template installs with a line of the form: +// +// uv tool install "$package_spec" && \ +// +// We write the override specs to a file (created via a heredoc RUN injected before the +// install step) and add "--overrides" to the install invocation, matching the install line +// by content rather than line number. +func injectUvOverrides(dockerfile string, constraints []ConstraintEntry) (string, error) { + const overridesFile = "/tmp/uv-overrides.txt" + + // Build a RUN step that writes the overrides requirements file. Each constraint is a + // PEP 508 requirement on its own line. + // Emit a single logical RUN that writes each spec (one per line) to the overrides file. + // Every printed line ends with a backslash continuation so the trailing redirect stays + // part of the same shell command and is not parsed as a new Dockerfile instruction. + var fileBuilder strings.Builder + fileBuilder.WriteString("# Write uv override requirements (forces pinned transitive dependency versions)\n") + fileBuilder.WriteString("RUN printf '%s\\n' \\\n") + for _, c := range constraints { + // Single-quote each spec for shell safety. + fmt.Fprintf(&fileBuilder, " '%s' \\\n", c.Spec) + } + fmt.Fprintf(&fileBuilder, " > %s", overridesFile) + overridesRun := fileBuilder.String() + + lines := strings.Split(dockerfile, "\n") + installIdx := -1 + for i, line := range lines { + // Match the actual install command, not Dockerfile comments that merely mention it. + // The toolhive template invokes it as: uv tool install "$package_spec" + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(line, "uv tool install \"") { + installIdx = i + break + } + } + if installIdx == -1 { + return "", fmt.Errorf("could not find the 'uv tool install' step in the generated Dockerfile to inject uv overrides") + } + + // Add the --overrides flag to the install invocation. + lines[installIdx] = strings.Replace( + lines[installIdx], + "uv tool install ", + fmt.Sprintf("uv tool install --overrides %s ", overridesFile), + 1, + ) + + // Insert the file-writing RUN step before the install step. The install step is often + // preceded by comment lines and a "RUN package=..." opener; we insert immediately before + // the line that opens the install RUN (the first line at or above installIdx that begins + // with "RUN "). + insertIdx := installIdx + for j := installIdx; j >= 0; j-- { + if strings.HasPrefix(strings.TrimSpace(lines[j]), "RUN ") { + insertIdx = j + break + } + } + + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:insertIdx]...) + out = append(out, overridesRun) + out = append(out, lines[insertIdx:]...) + + return strings.Join(out, "\n"), nil +} + // generateImageTag creates a container image tag based on the repository structure // Following the pattern: ghcr.io/stacklok/dockyard/{protocol}/{name}:{version} func generateImageTag(spec *MCPServerSpec) string { diff --git a/cmd/dockhand/main_test.go b/cmd/dockhand/main_test.go new file mode 100644 index 00000000..a0df6b5d --- /dev/null +++ b/cmd/dockhand/main_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "strings" + "testing" +) + +const ( + testOverrideVersion = "1.0.0" + testFastmcpSpec = "fastmcp>=3.2.0" +) + +// sampleNpxDockerfile mirrors the package.json + npm install steps that toolhive's +// BuildFromProtocolSchemeWithName emits for an npx package. +const sampleNpxDockerfile = `FROM node:24-alpine AS builder +WORKDIR /build + +# Create a package.json to install the MCP package +RUN echo '{"name":"mcp-container","version":"1.0.0"}' > package.json + +# Install the MCP package and its dependencies at build time +RUN npm install --save @brightdata/mcp@2.9.5 + +ENTRYPOINT ["npx", "@brightdata/mcp"] +` + +// sampleUvxDockerfile mirrors the "uv tool install" step that toolhive emits for a uvx package. +const sampleUvxDockerfile = `FROM python:3.14-slim AS builder +WORKDIR /build + +ENV UV_TOOL_DIR=/opt/uv-tools \ + UV_TOOL_BIN_DIR=/opt/uv-tools/bin +# Convert @ version separator to == for Python package specification +RUN package="mcp-clickhouse@0.3.0"; \ + package_spec=$(echo "$package" | sed 's/@/==/'); \ + uv tool install "$package_spec" && \ + ls -la /opt/uv-tools/bin/ + +ENTRYPOINT ["sh", "-c", "exec 'mcp-clickhouse' \"$@\"", "--"] +` + +func TestInjectNpmOverrides(t *testing.T) { + t.Parallel() + overrides := []OverrideEntry{ + {Package: "@modelcontextprotocol/sdk", Version: "1.26.0", Reason: "CVE fix; upstream hard-pins 1.21.2"}, + } + + out, err := injectNpmOverrides(sampleNpxDockerfile, overrides) + if err != nil { + t.Fatalf("injectNpmOverrides returned error: %v", err) + } + + // The package.json line must now carry an overrides block with the pinned version. + if !strings.Contains(out, `"overrides":`) { + t.Errorf("expected an overrides block in the generated package.json, got:\n%s", out) + } + if !strings.Contains(out, `"@modelcontextprotocol/sdk":"1.26.0"`) { + t.Errorf("expected the pinned SDK override in the package.json, got:\n%s", out) + } + + // The override must appear on the package.json line, which must precede the npm install. + pkgIdx := strings.Index(out, "> package.json") + installIdx := strings.Index(out, "npm install --save") + if pkgIdx == -1 || installIdx == -1 { + t.Fatalf("expected both the package.json step and the npm install step to be present") + } + if pkgIdx > installIdx { + t.Errorf("package.json (with overrides) must be created before npm install") + } + + // The npm install line must be left intact. + if !strings.Contains(out, "RUN npm install --save @brightdata/mcp@2.9.5") { + t.Errorf("npm install line should be unchanged, got:\n%s", out) + } +} + +func TestInjectUvOverrides(t *testing.T) { + t.Parallel() + constraints := []ConstraintEntry{ + {Spec: testFastmcpSpec, Reason: "CRITICAL CVE-2026-32871 fix; upstream caps <3.0.0"}, + } + + out, err := injectUvOverrides(sampleUvxDockerfile, constraints) + if err != nil { + t.Fatalf("injectUvOverrides returned error: %v", err) + } + + // The install step must now use the overrides file. + if !strings.Contains(out, "uv tool install --overrides /tmp/uv-overrides.txt") { + t.Errorf("expected --overrides flag on the uv tool install step, got:\n%s", out) + } + + // The overrides file must be written with the constraint spec. + if !strings.Contains(out, "'fastmcp>=3.2.0'") { + t.Errorf("expected the constraint spec to be written to the overrides file, got:\n%s", out) + } + if !strings.Contains(out, "> /tmp/uv-overrides.txt") { + t.Errorf("expected the overrides file to be written, got:\n%s", out) + } + + // The file-writing step must precede the install step. + writeIdx := strings.Index(out, "> /tmp/uv-overrides.txt") + installIdx := strings.Index(out, "uv tool install --overrides") + if writeIdx == -1 || installIdx == -1 { + t.Fatalf("expected both the overrides-file write and the install step") + } + if writeIdx > installIdx { + t.Errorf("overrides file must be written before uv tool install runs") + } +} + +func TestInjectDependencyOverrides_NoOp(t *testing.T) { + t.Parallel() + // npx spec with no overrides should pass the Dockerfile through unchanged. + spec := &MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolNpx}, + } + out, err := injectDependencyOverrides(sampleNpxDockerfile, spec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != sampleNpxDockerfile { + t.Errorf("expected Dockerfile to be unchanged when no overrides are set") + } + + // go protocol should also be a no-op even if (invalidly) overrides were present. + goSpec := &MCPServerSpec{Metadata: MCPServerMetadata{Protocol: protocolGo}} + out, err = injectDependencyOverrides("FROM golang:1.23\n", goSpec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "FROM golang:1.23\n" { + t.Errorf("expected go Dockerfile to be unchanged") + } +} + +func TestValidateDependencyOverrides(t *testing.T) { + t.Parallel() + tests := []struct { + name string + spec MCPServerSpec + wantErr bool + }{ + { + name: "valid npx override", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolNpx}, + Spec: MCPServerPackageSpec{ + Overrides: []OverrideEntry{{Package: "p", Version: testOverrideVersion, Reason: "because"}}, + }, + }, + wantErr: false, + }, + { + name: "npx override missing reason", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolNpx}, + Spec: MCPServerPackageSpec{ + Overrides: []OverrideEntry{{Package: "p", Version: testOverrideVersion}}, + }, + }, + wantErr: true, + }, + { + name: "npx override missing version", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolNpx}, + Spec: MCPServerPackageSpec{ + Overrides: []OverrideEntry{{Package: "p", Reason: "because"}}, + }, + }, + wantErr: true, + }, + { + name: "valid uvx constraint", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolUvx}, + Spec: MCPServerPackageSpec{ + Constraints: []ConstraintEntry{{Spec: testFastmcpSpec, Reason: "cve"}}, + }, + }, + wantErr: false, + }, + { + name: "uvx constraint missing reason", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolUvx}, + Spec: MCPServerPackageSpec{ + Constraints: []ConstraintEntry{{Spec: testFastmcpSpec}}, + }, + }, + wantErr: true, + }, + { + name: "overrides on uvx protocol rejected", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolUvx}, + Spec: MCPServerPackageSpec{ + Overrides: []OverrideEntry{{Package: "p", Version: testOverrideVersion, Reason: "x"}}, + }, + }, + wantErr: true, + }, + { + name: "constraints on npx protocol rejected", + spec: MCPServerSpec{ + Metadata: MCPServerMetadata{Protocol: protocolNpx}, + Spec: MCPServerPackageSpec{ + Constraints: []ConstraintEntry{{Spec: "x>=1", Reason: "x"}}, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateDependencyOverrides(&tt.spec) + if (err != nil) != tt.wantErr { + t.Errorf("validateDependencyOverrides() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/docs/adding-servers.md b/docs/adding-servers.md index d49ebc27..55762105 100644 --- a/docs/adding-servers.md +++ b/docs/adding-servers.md @@ -41,6 +41,21 @@ spec: - "arg1" # Passed to the entrypoint command - "arg2" + # Optional (npx only): force pinned versions of transitive npm dependencies. + # Injected as an "overrides" block in the generated package.json. Each entry + # requires a reason so the justification is auditable in-repo. + overrides: + - package: "@modelcontextprotocol/sdk" + version: "1.26.0" + reason: "Upstream hard-pins a vulnerable version; this same-major bump fixes it." + + # Optional (uvx only): force pinned versions of transitive Python dependencies. + # Written to a uv overrides requirements file and passed to `uv tool install + # --overrides`. Each entry requires a reason. + constraints: + - spec: "fastmcp>=3.2.0" + reason: "Upstream caps the dependency below the version that fixes a CVE." + provenance: # Optional but recommended repository_uri: "https://github.com/user/repo" repository_ref: "refs/tags/v1.0.0" @@ -138,6 +153,82 @@ provenance: repository_ref: "refs/tags/v0.3.1" ``` +## Dependency Overrides and Constraints + +Sometimes a package pins or caps a **transitive dependency** to a version that +fails the `build-containers` Grype gate (`--fail-on high --only-fixed`), and the +fix lives in a version excluded by that pin/cap. Dockyard can force a different +resolved version of the offending dependency without forking the upstream package. + +Every entry **must** include a `reason` (validation fails otherwise) so the +justification for circumventing an upstream pin is auditable in-repo, mirroring +`security.allowed_issues`. + +### npx: `spec.overrides` + +For `npx` servers, `spec.overrides` is injected as an [npm `overrides`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides) +block in the generated `package.json`, so npm resolves the pinned version +regardless of the upstream-declared range: + +```yaml +metadata: + name: brightdata-mcp + protocol: npx +spec: + package: "@brightdata/mcp" + version: "2.9.5" + overrides: + - package: "@modelcontextprotocol/sdk" + version: "1.26.0" + reason: | + @brightdata/mcp hard-pins @modelcontextprotocol/sdk 1.21.2 (3x HIGH); + fixes are >=1.24. 1.26.0 is same-major, so no API break. +``` + +This rewrites the package.json step in the Dockerfile to: + +```dockerfile +RUN echo '{"name":"mcp-container","overrides":{"@modelcontextprotocol/sdk":"1.26.0"},"version":"1.0.0"}' > package.json +``` + +### uvx: `spec.constraints` + +For `uvx` servers, each `spec.constraints[].spec` is a [PEP 508](https://peps.python.org/pep-0508/) +requirement written to a uv overrides requirements file and passed to +`uv tool install --overrides`, forcing the resolved version even when upstream +caps it: + +```yaml +metadata: + name: mcp-clickhouse + protocol: uvx +spec: + package: "mcp-clickhouse" + version: "0.3.0" + constraints: + - spec: "fastmcp>=3.2.0" + reason: | + mcp-clickhouse caps fastmcp <3.0.0, but the CRITICAL CVE-2026-32871 fix + is fastmcp 3.2.0. +``` + +This injects an overrides file and rewrites the install step in the Dockerfile to: + +```dockerfile +RUN printf '%s\n' \ + 'fastmcp>=3.2.0' \ + > /tmp/uv-overrides.txt +RUN package="mcp-clickhouse@0.3.0"; \ + package_spec=$(echo "$package" | sed 's/@/==/'); \ + uv tool install --overrides /tmp/uv-overrides.txt "$package_spec" && \ + ls -la /opt/uv-tools/bin/ +``` + +> **Caution:** Forcing a version across an upstream's *deliberate* cap can cross a +> major version boundary (e.g. fastmcp 2.x → 3.x) and break the server's tools at +> runtime even when the image builds and the package imports. Functionally test +> the server before relying on such an override. + ## Step-by-Step Process ### 1. Find Package Information