diff --git a/cmd/dockhand/main.go b/cmd/dockhand/main.go index b3ca5855..4d800373 100644 --- a/cmd/dockhand/main.go +++ b/cmd/dockhand/main.go @@ -42,10 +42,11 @@ type MCPServerMetadata struct { // MCPServerPackageSpec defines the package to be containerized 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 - Env map[string]string `yaml:"env,omitempty"` // Environment variables baked into the runtime image + 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 + Env map[string]string `yaml:"env,omitempty"` // Environment variables baked into the runtime image + BuildWith []string `yaml:"build_with,omitempty"` // Build-time dependency constraints (uvx:// only), e.g. "mcp<2" } // MCPServerProvenance contains supply chain provenance information @@ -370,10 +371,14 @@ func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag stri // Create image manager imageManager := images.NewImageManager(ctx) - // Pass runtime env vars through as a RuntimeConfig override, if declared + // Pass runtime env vars and build-time dependency constraints through as a + // RuntimeConfig override, if declared var runtimeOverride *templates.RuntimeConfig - if len(spec.Spec.Env) > 0 { - runtimeOverride = &templates.RuntimeConfig{RuntimeEnv: spec.Spec.Env} + if len(spec.Spec.Env) > 0 || len(spec.Spec.BuildWith) > 0 { + runtimeOverride = &templates.RuntimeConfig{ + RuntimeEnv: spec.Spec.Env, + BuildWith: spec.Spec.BuildWith, + } } // Generate Dockerfile using toolhive's BuildFromProtocolSchemeWithName function with dryRun=true diff --git a/docs/adding-servers.md b/docs/adding-servers.md index 9233577b..5701a82d 100644 --- a/docs/adding-servers.md +++ b/docs/adding-servers.md @@ -42,6 +42,8 @@ spec: - "arg2" env: # Optional: env vars baked into the runtime image SOME_VAR: "some-value" # Present in the running container, not just at build time + build_with: # Optional, uvx only: PEP 508 constraints on transitive deps + - "some-transitive-dep<2" # Passed to `uv tool install --with`; see uvx section below provenance: # Optional but recommended repository_uri: "https://github.com/user/repo" @@ -121,6 +123,30 @@ provenance: repository_ref: "refs/tags/v1.5.2" ``` +Some packages leave a transitive dependency unbounded upstream, which can break +the build the moment that dependency ships a breaking release. Constrain it +with `build_with` rather than waiting on an upstream fix: + +```yaml +# adb-mysql-mcp-server depends on mcp[cli]>=1.8.0 with no upper bound; mcp 2.0.0 +# removed the module this server imports at startup, so pin it below 2 until +# upstream caps the dependency themselves. +spec: + package: "adb-mysql-mcp-server" + version: "2.0.0" + build_with: + - "mcp<2" +# Results in: uv tool install --with 'mcp<2' adb-mysql-mcp-server==2.0.0 +``` + +`build_with` entries are PEP 508 requirement specifiers (exact pin, version +cap, or any other valid specifier) and only apply to `uvx://` builds; setting +them on an `npx` or `go` spec fails the build with an explicit error instead +of silently ignoring the constraint. The same values also apply to the +security scan (`scripts/mcp-scan`), which invokes the package directly via +`uvx --with ... package@version` so scanning matches what actually ships in +the built image. + ### Go ```yaml @@ -321,6 +347,7 @@ go build -o build/dockhand ./cmd/dockhand | Version error | Ensure version exists in package registry | | Wrong protocol | Verify package type matches directory (uvx/npx/go) | | Security scan fails | Review issues, allowlist false positives with explanation | +| `ModuleNotFoundError` from an unpinned transitive dep | Add a `build_with` constraint (uvx only), e.g. `mcp<2` | ## Key Rules diff --git a/scripts/mcp-scan/generate_mcp_config.py b/scripts/mcp-scan/generate_mcp_config.py index ed6aad7d..e1cc9a6e 100644 --- a/scripts/mcp-scan/generate_mcp_config.py +++ b/scripts/mcp-scan/generate_mcp_config.py @@ -30,9 +30,17 @@ def main(): spec_args = data['spec'].get('args', []) spec_args_str = ' '.join(spec_args) if spec_args else '' + # Build-time dependency constraints (uvx only, matches dockhand/ToolHive's + # --build-with). Passed to uvx's own --with flag so the scanner invokes + # the same constrained dependency set as the built container image. + build_with = data['spec'].get('build_with', []) + build_with_str = ' '.join(f"--with {c}" for c in build_with) if build_with else '' + if protocol in ['npx', 'uvx']: command = protocol args = f"{package}@{version}" + if protocol == 'uvx' and build_with_str: + args = f"{build_with_str} {args}" if spec_args_str: args = f"{args} {spec_args_str}" elif protocol == 'go': diff --git a/scripts/mcp-scan/run_scan.py b/scripts/mcp-scan/run_scan.py index 254924ca..c1e8ada4 100644 --- a/scripts/mcp-scan/run_scan.py +++ b/scripts/mcp-scan/run_scan.py @@ -67,7 +67,13 @@ def main(): if command == "npx": scanner_args.append("--stdio-arg=--yes") for arg in package_arg.split(): - scanner_args.extend(["--stdio-arg", arg]) + # Args that themselves start with "-" (e.g. uvx's "--with") are + # indistinguishable from a new flag to argparse when passed as a + # separate token after "--stdio-arg", so fold them into one token. + if arg.startswith("-"): + scanner_args.append(f"--stdio-arg={arg}") + else: + scanner_args.extend(["--stdio-arg", arg]) # Add mock environment variables for servers that require them # mcp-scanner supports --stdio-env KEY=VALUE (can be repeated) diff --git a/uvx/adb-mysql-mcp-server/spec.yaml b/uvx/adb-mysql-mcp-server/spec.yaml index 378bebc1..caa685ef 100644 --- a/uvx/adb-mysql-mcp-server/spec.yaml +++ b/uvx/adb-mysql-mcp-server/spec.yaml @@ -12,6 +12,12 @@ metadata: spec: package: "adb-mysql-mcp-server" version: "2.0.0" + # adb-mysql-mcp-server depends on `mcp[cli]>=1.8.0` with no upper bound. + # mcp 2.0.0 removed the mcp.server.fastmcp module this server imports at + # startup, so cap the transitive `mcp` dependency until upstream pins it + # themselves. See https://github.com/aliyun/alibabacloud-adb-mysql-mcp-server + build_with: + - "mcp<2" provenance: repository_uri: "https://github.com/aliyun/alibabacloud-adb-mysql-mcp-server"