Skip to content

Commit e2d46fb

Browse files
authored
Merge pull request #78 from githubnext/copilot/update-mcp-gateway-spec
Add support for entrypoint and mounts configuration fields
2 parents 456bccb + 09a61fd commit e2d46fb

7 files changed

Lines changed: 456 additions & 20 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ For the complete JSON configuration specification with all validation rules, see
6767
"github": {
6868
"type": "stdio",
6969
"container": "ghcr.io/github/github-mcp-server:latest",
70+
"entrypoint": "/custom/entrypoint.sh",
7071
"entrypointArgs": ["--verbose"],
72+
"mounts": [
73+
"/host/config:/app/config:ro",
74+
"/host/data:/app/data:rw"
75+
],
7176
"env": {
7277
"GITHUB_PERSONAL_ACCESS_TOKEN": "",
7378
"EXPANDED_VAR": "${MY_HOME}/config"
@@ -94,11 +99,26 @@ For the complete JSON configuration specification with all validation rules, see
9499
- **`container`** (required for stdio): Docker container image (e.g., `"ghcr.io/github/github-mcp-server:latest"`)
95100
- Automatically wraps as `docker run --rm -i <container>`
96101
- **Note**: The `command` field is NOT supported per the specification
102+
103+
- **`entrypoint`** (optional): Custom entrypoint for the container
104+
- Overrides the default container entrypoint
105+
- Applied as `--entrypoint` flag to Docker
106+
97107
- **`entrypointArgs`** (optional): Arguments passed to container entrypoint
108+
- Array of strings passed after the container image
109+
110+
- **`mounts`** (optional): Volume mounts for the container
111+
- Array of strings in format `"source:dest:mode"`
112+
- `source` - Host path to mount (can use environment variables with `${VAR}` syntax)
113+
- `dest` - Container path where the volume is mounted
114+
- `mode` - Either `"ro"` (read-only) or `"rw"` (read-write)
115+
- Example: `["/host/config:/app/config:ro", "/host/data:/app/data:rw"]`
116+
98117
- **`env`** (optional): Environment variables
99118
- Set to `""` (empty string) for passthrough from host environment
100119
- Set to `"value"` for explicit value
101120
- Use `"${VAR_NAME}"` for environment variable expansion (fails if undefined)
121+
102122
- **`url`** (required for http): HTTP endpoint URL for `type: "http"` servers
103123

104124
**Validation Rules:**
@@ -109,6 +129,9 @@ For the complete JSON configuration specification with all validation rules, see
109129
- Variable expansion with `${VAR_NAME}` fails fast on undefined variables
110130
- All validation errors include JSONPath and helpful suggestions
111131
- **The `command` field is not supported** - stdio servers must use `container`
132+
- **Mount specifications** must follow `"source:dest:mode"` format
133+
- `mode` must be either `"ro"` or `"rw"`
134+
- Both source and destination paths are required (cannot be empty)
112135

113136
See **[Configuration Specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md)** for complete validation rules.
114137

config.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@
1414
"memory": {
1515
"type": "local",
1616
"container": "mcp/memory"
17+
},
18+
"custom-app": {
19+
"type": "stdio",
20+
"container": "myorg/custom-mcp:latest",
21+
"entrypoint": "/custom/entrypoint.sh",
22+
"entrypointArgs": ["--verbose", "--debug"],
23+
"mounts": [
24+
"/host/config:/app/config:ro",
25+
"/host/data:/app/data:rw"
26+
],
27+
"env": {
28+
"API_KEY": "${CUSTOM_API_KEY}",
29+
"DEBUG": "true"
30+
}
1731
}
1832
}
1933
}

go.mod

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,14 @@ go 1.25.0
44

55
require (
66
github.com/BurntSushi/toml v1.5.0
7+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
78
github.com/modelcontextprotocol/go-sdk v1.1.0
89
github.com/spf13/cobra v1.10.2
910
golang.org/x/term v0.38.0
10-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
11-
)
12-
13-
require (
14-
github.com/google/jsonschema-go v0.3.0 // indirect
15-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
16-
github.com/spf13/pflag v1.0.9 // indirect
17-
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
18-
golang.org/x/oauth2 v0.30.0 // indirect
19-
golang.org/x/sys v0.39.0 // indirect
2011
)
2112

2213
require (
2314
github.com/google/jsonschema-go v0.3.0 // indirect
24-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
25-
github.com/spf13/pflag v1.0.9 // indirect
26-
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
27-
golang.org/x/oauth2 v0.30.0 // indirect
28-
golang.org/x/sys v0.39.0 // indirect
29-
)
30-
31-
require (
32-
github.com/google/jsonschema-go v0.3.0 // indirect
33-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3415
github.com/spf13/pflag v1.0.9 // indirect
3516
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
3617
golang.org/x/oauth2 v0.30.0 // indirect

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ type StdinServerConfig struct {
5050
Args []string `json:"args,omitempty"`
5151
Env map[string]string `json:"env,omitempty"`
5252
Container string `json:"container,omitempty"`
53+
Entrypoint string `json:"entrypoint,omitempty"`
5354
EntrypointArgs []string `json:"entrypointArgs,omitempty"`
55+
Mounts []string `json:"mounts,omitempty"`
5456
URL string `json:"url,omitempty"` // For HTTP-based MCP servers
5557
}
5658

@@ -180,6 +182,16 @@ func LoadFromStdin() (*Config, error) {
180182
"-e", "PYTHONUNBUFFERED=1",
181183
}
182184

185+
// Add entrypoint override if specified
186+
if server.Entrypoint != "" {
187+
args = append(args, "--entrypoint", server.Entrypoint)
188+
}
189+
190+
// Add volume mounts if specified
191+
for _, mount := range server.Mounts {
192+
args = append(args, "-v", mount)
193+
}
194+
183195
// Add user-specified environment variables
184196
// Empty string "" means passthrough from host (just -e KEY)
185197
// Non-empty string means explicit value (-e KEY=value)

internal/config/config_test.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"strings"
78
"testing"
@@ -656,3 +657,253 @@ func contains(slice []string, item string) bool {
656657
}
657658
return false
658659
}
660+
661+
func TestLoadFromStdin_WithEntrypoint(t *testing.T) {
662+
jsonConfig := `{
663+
"mcpServers": {
664+
"custom": {
665+
"type": "stdio",
666+
"container": "test/container:latest",
667+
"entrypoint": "/custom/entrypoint.sh",
668+
"entrypointArgs": ["--verbose"]
669+
}
670+
}
671+
}`
672+
673+
r, w, _ := os.Pipe()
674+
oldStdin := os.Stdin
675+
os.Stdin = r
676+
go func() {
677+
w.Write([]byte(jsonConfig))
678+
w.Close()
679+
}()
680+
681+
cfg, err := LoadFromStdin()
682+
os.Stdin = oldStdin
683+
684+
if err != nil {
685+
t.Fatalf("LoadFromStdin() failed: %v", err)
686+
}
687+
688+
server, ok := cfg.Servers["custom"]
689+
if !ok {
690+
t.Fatal("Server 'custom' not found")
691+
}
692+
693+
// Check that --entrypoint flag is present
694+
hasEntrypoint := false
695+
for i := 0; i < len(server.Args); i++ {
696+
if server.Args[i] == "--entrypoint" && i+1 < len(server.Args) {
697+
if server.Args[i+1] == "/custom/entrypoint.sh" {
698+
hasEntrypoint = true
699+
}
700+
}
701+
}
702+
703+
if !hasEntrypoint {
704+
t.Error("Entrypoint flag not found in Docker args")
705+
}
706+
707+
// Check that entrypoint args are present
708+
if !contains(server.Args, "--verbose") {
709+
t.Error("Entrypoint args not found")
710+
}
711+
}
712+
713+
func TestLoadFromStdin_WithMounts(t *testing.T) {
714+
jsonConfig := `{
715+
"mcpServers": {
716+
"mounted": {
717+
"type": "stdio",
718+
"container": "test/container:latest",
719+
"mounts": [
720+
"/host/path:/container/path:ro",
721+
"/host/data:/app/data:rw"
722+
]
723+
}
724+
}
725+
}`
726+
727+
r, w, _ := os.Pipe()
728+
oldStdin := os.Stdin
729+
os.Stdin = r
730+
go func() {
731+
w.Write([]byte(jsonConfig))
732+
w.Close()
733+
}()
734+
735+
cfg, err := LoadFromStdin()
736+
os.Stdin = oldStdin
737+
738+
if err != nil {
739+
t.Fatalf("LoadFromStdin() failed: %v", err)
740+
}
741+
742+
server, ok := cfg.Servers["mounted"]
743+
if !ok {
744+
t.Fatal("Server 'mounted' not found")
745+
}
746+
747+
// Check that volume mount flags are present
748+
mountCount := 0
749+
for i := 0; i < len(server.Args); i++ {
750+
if server.Args[i] == "-v" && i+1 < len(server.Args) {
751+
nextArg := server.Args[i+1]
752+
if nextArg == "/host/path:/container/path:ro" || nextArg == "/host/data:/app/data:rw" {
753+
mountCount++
754+
}
755+
}
756+
}
757+
758+
if mountCount != 2 {
759+
t.Errorf("Expected 2 volume mounts, found %d", mountCount)
760+
}
761+
}
762+
763+
func TestLoadFromStdin_WithAllNewFields(t *testing.T) {
764+
jsonConfig := `{
765+
"mcpServers": {
766+
"comprehensive": {
767+
"type": "stdio",
768+
"container": "test/container:latest",
769+
"entrypoint": "/bin/bash",
770+
"entrypointArgs": ["-c", "echo test"],
771+
"mounts": ["/tmp:/data:rw"],
772+
"env": {
773+
"DEBUG": "true"
774+
}
775+
}
776+
}
777+
}`
778+
779+
r, w, _ := os.Pipe()
780+
oldStdin := os.Stdin
781+
os.Stdin = r
782+
go func() {
783+
w.Write([]byte(jsonConfig))
784+
w.Close()
785+
}()
786+
787+
cfg, err := LoadFromStdin()
788+
os.Stdin = oldStdin
789+
790+
if err != nil {
791+
t.Fatalf("LoadFromStdin() failed: %v", err)
792+
}
793+
794+
server, ok := cfg.Servers["comprehensive"]
795+
if !ok {
796+
t.Fatal("Server 'comprehensive' not found")
797+
}
798+
799+
// Verify command is docker
800+
if server.Command != "docker" {
801+
t.Errorf("Expected command 'docker', got '%s'", server.Command)
802+
}
803+
804+
// Check entrypoint
805+
hasEntrypoint := false
806+
for i := 0; i < len(server.Args)-1; i++ {
807+
if server.Args[i] == "--entrypoint" && server.Args[i+1] == "/bin/bash" {
808+
hasEntrypoint = true
809+
break
810+
}
811+
}
812+
if !hasEntrypoint {
813+
t.Error("Entrypoint not found in args")
814+
}
815+
816+
// Check mounts
817+
hasMount := false
818+
for i := 0; i < len(server.Args)-1; i++ {
819+
if server.Args[i] == "-v" && server.Args[i+1] == "/tmp:/data:rw" {
820+
hasMount = true
821+
break
822+
}
823+
}
824+
if !hasMount {
825+
t.Error("Mount not found in args")
826+
}
827+
828+
// Check env var
829+
hasDebug := false
830+
for i := 0; i < len(server.Args)-1; i++ {
831+
if server.Args[i] == "-e" && server.Args[i+1] == "DEBUG=true" {
832+
hasDebug = true
833+
break
834+
}
835+
}
836+
if !hasDebug {
837+
t.Error("Environment variable DEBUG=true not found")
838+
}
839+
840+
// Check entrypoint args
841+
if !contains(server.Args, "-c") || !contains(server.Args, "echo test") {
842+
t.Error("Entrypoint args not found")
843+
}
844+
845+
// Verify container name is present
846+
if !contains(server.Args, "test/container:latest") {
847+
t.Error("Container name not found")
848+
}
849+
}
850+
851+
func TestLoadFromStdin_InvalidMountFormat(t *testing.T) {
852+
tests := []struct {
853+
name string
854+
mounts string
855+
errorMsg string
856+
}{
857+
{
858+
name: "missing mode",
859+
mounts: `["/host:/container"]`,
860+
errorMsg: "invalid mount format",
861+
},
862+
{
863+
name: "invalid mode",
864+
mounts: `["/host:/container:invalid"]`,
865+
errorMsg: "invalid mount mode",
866+
},
867+
{
868+
name: "empty source",
869+
mounts: `[":/container:ro"]`,
870+
errorMsg: "mount source cannot be empty",
871+
},
872+
{
873+
name: "empty destination",
874+
mounts: `["/host::ro"]`,
875+
errorMsg: "mount destination cannot be empty",
876+
},
877+
}
878+
879+
for _, tt := range tests {
880+
t.Run(tt.name, func(t *testing.T) {
881+
jsonConfig := fmt.Sprintf(`{
882+
"mcpServers": {
883+
"test": {
884+
"type": "stdio",
885+
"container": "test:latest",
886+
"mounts": %s
887+
}
888+
}
889+
}`, tt.mounts)
890+
891+
r, w, _ := os.Pipe()
892+
oldStdin := os.Stdin
893+
os.Stdin = r
894+
go func() {
895+
w.Write([]byte(jsonConfig))
896+
w.Close()
897+
}()
898+
899+
_, err := LoadFromStdin()
900+
os.Stdin = oldStdin
901+
902+
if err == nil {
903+
t.Error("Expected error but got none")
904+
} else if !strings.Contains(err.Error(), tt.errorMsg) {
905+
t.Errorf("Expected error containing %q, got: %v", tt.errorMsg, err)
906+
}
907+
})
908+
}
909+
}

0 commit comments

Comments
 (0)