@@ -3,6 +3,7 @@ package main
33
44import (
55 "context"
6+ "encoding/json"
67 "fmt"
78 "log/slog"
89 "os"
@@ -22,6 +23,17 @@ import (
2223 skillpkg "github.com/stacklok/dockyard/internal/skills"
2324)
2425
26+ // Supported package protocols.
27+ const (
28+ protocolNpx = "npx"
29+ protocolUvx = "uvx"
30+ protocolGo = "go"
31+
32+ // mcpContainerVersion is the placeholder version toolhive's npx template stamps into
33+ // the generated package.json; we reuse it when re-emitting that file with overrides.
34+ mcpContainerVersion = "1.0.0"
35+ )
36+
2537// MCPServerSpec defines the structure of our YAML configuration files
2638type MCPServerSpec struct {
2739 // Metadata about the MCP server
@@ -44,6 +56,33 @@ type MCPServerPackageSpec struct {
4456 Package string `yaml:"package"` // e.g., "@upstash/context7-mcp"
4557 Version string `yaml:"version,omitempty"` // e.g., "1.0.14"
4658 Args []string `yaml:"args,omitempty"` // Additional arguments for the package
59+
60+ // Overrides forces specific versions of transitive npm dependencies (npx protocol).
61+ // Each entry is injected into an "overrides" block of the generated package.json so
62+ // that npm resolves the pinned version regardless of upstream's declared range.
63+ Overrides []OverrideEntry `yaml:"overrides,omitempty"`
64+
65+ // Constraints forces specific versions of transitive Python dependencies (uvx protocol).
66+ // Each entry is written to a uv overrides requirements file and passed to
67+ // "uv tool install --overrides" so that uv resolves the pinned version even when
68+ // upstream caps the dependency.
69+ Constraints []ConstraintEntry `yaml:"constraints,omitempty"`
70+ }
71+
72+ // OverrideEntry pins a transitive npm dependency to a specific version (npx protocol).
73+ // Reason is mandatory so the justification for circumventing the upstream pin is auditable
74+ // in-repo, mirroring security.allowed_issues.
75+ type OverrideEntry struct {
76+ Package string `yaml:"package"` // e.g., "@modelcontextprotocol/sdk"
77+ Version string `yaml:"version"` // e.g., "1.26.0"
78+ Reason string `yaml:"reason"` // why this override is needed (required)
79+ }
80+
81+ // ConstraintEntry pins a transitive Python dependency via a uv override requirement
82+ // (uvx protocol). Reason is mandatory so the justification is auditable in-repo.
83+ type ConstraintEntry struct {
84+ Spec string `yaml:"spec"` // a PEP 508 requirement, e.g., "fastmcp>=3.2.0"
85+ Reason string `yaml:"reason"` // why this constraint is needed (required)
4786}
4887
4988// MCPServerProvenance contains supply chain provenance information
@@ -335,7 +374,7 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) {
335374 }
336375
337376 // Validate protocol
338- validProtocols := []string {"npx" , "uvx" , "go" }
377+ validProtocols := []string {protocolNpx , protocolUvx , protocolGo }
339378 isValid := false
340379 for _ , p := range validProtocols {
341380 if spec .Metadata .Protocol == p {
@@ -347,9 +386,49 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) {
347386 return nil , fmt .Errorf ("invalid protocol %s, must be one of: %v" , spec .Metadata .Protocol , validProtocols )
348387 }
349388
389+ // Validate dependency overrides/constraints
390+ if err := validateDependencyOverrides (& spec ); err != nil {
391+ return nil , err
392+ }
393+
350394 return & spec , nil
351395}
352396
397+ // validateDependencyOverrides validates the optional overrides (npx) and constraints
398+ // (uvx) blocks. Every entry must carry a non-empty Reason so the justification for
399+ // circumventing an upstream version pin is auditable in-repo.
400+ func validateDependencyOverrides (spec * MCPServerSpec ) error {
401+ if len (spec .Spec .Overrides ) > 0 && spec .Metadata .Protocol != protocolNpx {
402+ return fmt .Errorf ("spec.overrides is only supported for the npx protocol, got %q" , spec .Metadata .Protocol )
403+ }
404+ if len (spec .Spec .Constraints ) > 0 && spec .Metadata .Protocol != protocolUvx {
405+ return fmt .Errorf ("spec.constraints is only supported for the uvx protocol, got %q" , spec .Metadata .Protocol )
406+ }
407+
408+ for i , o := range spec .Spec .Overrides {
409+ if o .Package == "" {
410+ return fmt .Errorf ("spec.overrides[%d].package is required" , i )
411+ }
412+ if o .Version == "" {
413+ return fmt .Errorf ("spec.overrides[%d].version is required" , i )
414+ }
415+ if strings .TrimSpace (o .Reason ) == "" {
416+ return fmt .Errorf ("spec.overrides[%d].reason is required (document why %s is pinned to %s)" , i , o .Package , o .Version )
417+ }
418+ }
419+
420+ for i , c := range spec .Spec .Constraints {
421+ if strings .TrimSpace (c .Spec ) == "" {
422+ return fmt .Errorf ("spec.constraints[%d].spec is required" , i )
423+ }
424+ if strings .TrimSpace (c .Reason ) == "" {
425+ return fmt .Errorf ("spec.constraints[%d].reason is required (document why %q is constrained)" , i , c .Spec )
426+ }
427+ }
428+
429+ return nil
430+ }
431+
353432// generateDockerfile generates a Dockerfile using toolhive's library
354433func generateDockerfile (ctx context.Context , spec * MCPServerSpec , customTag string ) (string , error ) {
355434 // Create the protocol scheme string
@@ -383,9 +462,159 @@ func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag stri
383462 return "" , fmt .Errorf ("failed to generate Dockerfile for protocol scheme %s: %w" , protocolScheme , err )
384463 }
385464
465+ // Post-process the generated Dockerfile to inject any dependency overrides.
466+ // toolhive returns the Dockerfile as a string, which is our injection seam; toolhive
467+ // itself needs no changes.
468+ dockerfile , err = injectDependencyOverrides (dockerfile , spec )
469+ if err != nil {
470+ return "" , fmt .Errorf ("failed to inject dependency overrides: %w" , err )
471+ }
472+
386473 return dockerfile , nil
387474}
388475
476+ // injectDependencyOverrides rewrites the generated Dockerfile to force pinned versions
477+ // of transitive dependencies. For npx it injects an npm "overrides" block; for uvx it
478+ // adds a uv overrides requirements file to the "uv tool install" step. It matches the
479+ // relevant install step by content (not line number) so it stays robust to changes in
480+ // toolhive's template formatting.
481+ func injectDependencyOverrides (dockerfile string , spec * MCPServerSpec ) (string , error ) {
482+ switch spec .Metadata .Protocol {
483+ case protocolNpx :
484+ if len (spec .Spec .Overrides ) == 0 {
485+ return dockerfile , nil
486+ }
487+ return injectNpmOverrides (dockerfile , spec .Spec .Overrides )
488+ case protocolUvx :
489+ if len (spec .Spec .Constraints ) == 0 {
490+ return dockerfile , nil
491+ }
492+ return injectUvOverrides (dockerfile , spec .Spec .Constraints )
493+ default :
494+ return dockerfile , nil
495+ }
496+ }
497+
498+ // injectNpmOverrides rewrites the package.json creation step so the generated package.json
499+ // carries an "overrides" block. npm honors "overrides" only when present in the package.json
500+ // it installs into, so this is injected before the "npm install" step. The toolhive template
501+ // creates the package.json with a line of the form:
502+ //
503+ // RUN echo '{"name":"mcp-container","version":"1.0.0"}' > package.json
504+ //
505+ // We locate that line by content (the "> package.json" redirect) and replace the JSON payload
506+ // with one that includes the overrides.
507+ func injectNpmOverrides (dockerfile string , overrides []OverrideEntry ) (string , error ) {
508+ overrideMap := make (map [string ]string , len (overrides ))
509+ for _ , o := range overrides {
510+ overrideMap [o .Package ] = o .Version
511+ }
512+
513+ // Mirror the package.json name/version that toolhive's npx template emits, adding the
514+ // overrides block.
515+ pkgJSON := map [string ]any {
516+ "name" : "mcp-container" ,
517+ "version" : mcpContainerVersion ,
518+ "overrides" : overrideMap ,
519+ }
520+ pkgJSONBytes , err := json .Marshal (pkgJSON )
521+ if err != nil {
522+ return "" , fmt .Errorf ("failed to marshal package.json with overrides: %w" , err )
523+ }
524+
525+ lines := strings .Split (dockerfile , "\n " )
526+ injected := false
527+ for i , line := range lines {
528+ trimmed := strings .TrimSpace (line )
529+ // Match the package.json creation step regardless of the exact JSON payload.
530+ if strings .HasPrefix (trimmed , "RUN echo '" ) && strings .Contains (trimmed , "> package.json" ) {
531+ lines [i ] = fmt .Sprintf ("RUN echo '%s' > package.json" , string (pkgJSONBytes ))
532+ injected = true
533+ break
534+ }
535+ }
536+
537+ if ! injected {
538+ return "" , fmt .Errorf ("could not find the 'package.json' creation step in the generated Dockerfile to inject npm overrides" )
539+ }
540+
541+ return strings .Join (lines , "\n " ), nil
542+ }
543+
544+ // injectUvOverrides rewrites the "uv tool install" step so it passes a uv overrides
545+ // requirements file. uv honors override requirements via "--overrides <file>", forcing the
546+ // resolved version of a transitive dependency even when upstream caps it. The toolhive
547+ // template installs with a line of the form:
548+ //
549+ // uv tool install "$package_spec" && \
550+ //
551+ // We write the override specs to a file (created via a heredoc RUN injected before the
552+ // install step) and add "--overrides" to the install invocation, matching the install line
553+ // by content rather than line number.
554+ func injectUvOverrides (dockerfile string , constraints []ConstraintEntry ) (string , error ) {
555+ const overridesFile = "/tmp/uv-overrides.txt"
556+
557+ // Build a RUN step that writes the overrides requirements file. Each constraint is a
558+ // PEP 508 requirement on its own line.
559+ // Emit a single logical RUN that writes each spec (one per line) to the overrides file.
560+ // Every printed line ends with a backslash continuation so the trailing redirect stays
561+ // part of the same shell command and is not parsed as a new Dockerfile instruction.
562+ var fileBuilder strings.Builder
563+ fileBuilder .WriteString ("# Write uv override requirements (forces pinned transitive dependency versions)\n " )
564+ fileBuilder .WriteString ("RUN printf '%s\\ n' \\ \n " )
565+ for _ , c := range constraints {
566+ // Single-quote each spec for shell safety.
567+ fmt .Fprintf (& fileBuilder , " '%s' \\ \n " , c .Spec )
568+ }
569+ fmt .Fprintf (& fileBuilder , " > %s" , overridesFile )
570+ overridesRun := fileBuilder .String ()
571+
572+ lines := strings .Split (dockerfile , "\n " )
573+ installIdx := - 1
574+ for i , line := range lines {
575+ // Match the actual install command, not Dockerfile comments that merely mention it.
576+ // The toolhive template invokes it as: uv tool install "$package_spec"
577+ trimmed := strings .TrimSpace (line )
578+ if strings .HasPrefix (trimmed , "#" ) {
579+ continue
580+ }
581+ if strings .Contains (line , "uv tool install \" " ) {
582+ installIdx = i
583+ break
584+ }
585+ }
586+ if installIdx == - 1 {
587+ return "" , fmt .Errorf ("could not find the 'uv tool install' step in the generated Dockerfile to inject uv overrides" )
588+ }
589+
590+ // Add the --overrides flag to the install invocation.
591+ lines [installIdx ] = strings .Replace (
592+ lines [installIdx ],
593+ "uv tool install " ,
594+ fmt .Sprintf ("uv tool install --overrides %s " , overridesFile ),
595+ 1 ,
596+ )
597+
598+ // Insert the file-writing RUN step before the install step. The install step is often
599+ // preceded by comment lines and a "RUN package=..." opener; we insert immediately before
600+ // the line that opens the install RUN (the first line at or above installIdx that begins
601+ // with "RUN ").
602+ insertIdx := installIdx
603+ for j := installIdx ; j >= 0 ; j -- {
604+ if strings .HasPrefix (strings .TrimSpace (lines [j ]), "RUN " ) {
605+ insertIdx = j
606+ break
607+ }
608+ }
609+
610+ out := make ([]string , 0 , len (lines )+ 1 )
611+ out = append (out , lines [:insertIdx ]... )
612+ out = append (out , overridesRun )
613+ out = append (out , lines [insertIdx :]... )
614+
615+ return strings .Join (out , "\n " ), nil
616+ }
617+
389618// generateImageTag creates a container image tag based on the repository structure
390619// Following the pattern: ghcr.io/stacklok/dockyard/{protocol}/{name}:{version}
391620func generateImageTag (spec * MCPServerSpec ) string {
0 commit comments