Skip to content

Commit a0c81de

Browse files
authored
Add configurable session-segmented payload directory for agent access (#569)
## Implementation Plan: Shared Payload Directory for Large Payloads - [x] Add configuration support for payload directory - [x] Add `PayloadDir` field to `GatewayConfig` struct - [x] Add support in TOML config (`payload_dir`) - [x] Add support in JSON/stdin config (`payloadDir`) - [x] Add command-line flag `--payload-dir` - [x] Add environment variable `MCP_GATEWAY_PAYLOAD_DIR` - [x] Set default to `/tmp/jq-payloads` - [x] Update jqschema middleware to use session-based payload directories - [x] Modify `savePayload` to accept session ID and base payload directory - [x] Create session-specific subdirectories (e.g., `/tmp/jq-payloads/{sessionID}/`) - [x] Update `WrapToolHandler` to pass session ID from context - [x] Use restrictive permissions (0600 for files, 0700 for directories) - [x] Update middleware tests - [x] Fix test signatures to match new WrapToolHandler parameters - [x] Update test assertions for new directory structure - [x] Add comprehensive tests for new functionality - [x] Config parsing tests for payload_dir (TOML) - [x] Config parsing tests for default payload_dir - [x] Test session directory creation - [x] Test payloadDir validation with absolute path requirement - [x] Update documentation - [x] Update AGENTS.md with new configuration option - [x] Document environment variable `MCP_GATEWAY_PAYLOAD_DIR` - [x] Document command-line flag `--payload-dir` - [x] Document large payload handling mechanism - [x] Security improvements - [x] Use restrictive file permissions (0600) for payload files - [x] Use restrictive directory permissions (0700) for payload directories - [x] Session directory creation - [x] Gateway checks if session subdirectory exists on session creation - [x] Creates session directory if it doesn't exist - [x] Added test for session directory creation - [x] Schema validation - [x] Added validation for payloadDir field requiring absolute paths - [x] Validation matches schema pattern: ^(/|[A-Za-z]:\\) - [x] Added comprehensive tests for Unix and Windows paths - [x] Validation conforms to official MCP Gateway schema - [x] Fix linting issues - [x] Run gofmt on all modified files - [x] Run full test suite and verify changes ## Summary All implementation tasks are complete. The gateway now: 1. Supports configurable payload directories with session-based segmentation 2. Automatically creates session subdirectories when sessions are initialized 3. Validates payloadDir as an absolute path per the gateway schema spec 4. Allows agents to mount their own session subdirectory and access large payloads as regular files 5. All code is properly formatted and passes linting checks 6. payloadDir validation strictly conforms to the official MCP Gateway schema specification <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.
2 parents f0c7a54 + 10dd426 commit a0c81de

File tree

13 files changed

+580
-42
lines changed

13 files changed

+580
-42
lines changed

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Quick reference for AI agents working with MCP Gateway (Go-based MCP proxy serve
1717
**Agent-Finished**: `make agent-finished` (run format, build, lint, and all tests - ALWAYS run before completion)
1818
**Run**: `./awmg --config config.toml`
1919
**Run with Custom Log Directory**: `./awmg --config config.toml --log-dir /path/to/logs`
20+
**Run with Custom Payload Directory**: `./awmg --config config.toml --payload-dir /path/to/payloads`
2021

2122
## Project Structure
2223

@@ -47,6 +48,11 @@ Quick reference for AI agents working with MCP Gateway (Go-based MCP proxy serve
4748

4849
**TOML** (`config.toml`):
4950
```toml
51+
[gateway]
52+
port = 3000
53+
api_key = "your-api-key"
54+
payload_dir = "/tmp/jq-payloads" # Optional: directory for large payload storage
55+
5056
[servers.github]
5157
command = "docker"
5258
args = ["run", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "-i", "ghcr.io/github/github-mcp-server:latest"]
@@ -357,13 +363,21 @@ DEBUG_COLORS=0 DEBUG=* ./awmg --config config.toml
357363
- `DEBUG` - Enable debug logging (e.g., `DEBUG=*`, `DEBUG=server:*,launcher:*`)
358364
- `DEBUG_COLORS` - Control colored output (0 to disable, auto-disabled when piping)
359365
- `MCP_GATEWAY_LOG_DIR` - Log file directory (sets default for `--log-dir` flag, default: `/tmp/gh-aw/mcp-logs`)
366+
- `MCP_GATEWAY_PAYLOAD_DIR` - Large payload storage directory (sets default for `--payload-dir` flag, default: `/tmp/jq-payloads`)
360367

361368
**File Logging:**
362369
- Operational logs are always written to `mcp-gateway.log` in the configured log directory
363370
- Default log directory: `/tmp/gh-aw/mcp-logs` (configurable via `--log-dir` flag or `MCP_GATEWAY_LOG_DIR` env var)
364371
- Falls back to stdout if log directory cannot be created
365372
- Logs include: startup, client interactions, backend operations, auth events, errors
366373

374+
**Large Payload Handling:**
375+
- Large tool response payloads are stored in the configured payload directory
376+
- Default payload directory: `/tmp/jq-payloads` (configurable via `--payload-dir` flag, `MCP_GATEWAY_PAYLOAD_DIR` env var, or `payload_dir` in config)
377+
- Payloads are organized by session ID: `{payload_dir}/{sessionID}/{queryID}/payload.json`
378+
- This allows agents to mount their session-specific subdirectory to access full payloads
379+
- The jq middleware returns: preview (first 500 chars), schema, payloadPath, queryID, originalSize, truncated flag
380+
367381
## Error Debugging
368382

369383
**Enhanced Error Context**: Command failures include:

internal/cmd/root.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
defaultEnvFile = ""
3535
defaultEnableDIFC = false
3636
defaultLogDir = "/tmp/gh-aw/mcp-logs"
37+
defaultPayloadDir = "/tmp/jq-payloads"
3738
defaultSequentialLaunch = false
3839
)
3940

@@ -46,6 +47,7 @@ var (
4647
envFile string
4748
enableDIFC bool
4849
logDir string
50+
payloadDir string
4951
validateEnv bool
5052
sequentialLaunch bool
5153
verbosity int // Verbosity level: 0 (default), 1 (-v info), 2 (-vv debug), 3 (-vvv trace)
@@ -76,6 +78,7 @@ func init() {
7678
rootCmd.Flags().StringVar(&envFile, "env", defaultEnvFile, "Path to .env file to load environment variables")
7779
rootCmd.Flags().BoolVar(&enableDIFC, "enable-difc", defaultEnableDIFC, "Enable DIFC enforcement and session requirement (requires sys___init call before tool access)")
7880
rootCmd.Flags().StringVar(&logDir, "log-dir", getDefaultLogDir(), "Directory for log files (falls back to stdout if directory cannot be created)")
81+
rootCmd.Flags().StringVar(&payloadDir, "payload-dir", getDefaultPayloadDir(), "Directory for storing large payload files (segmented by session ID)")
7982
rootCmd.Flags().BoolVar(&validateEnv, "validate-env", false, "Validate execution environment (Docker, env vars) before starting")
8083
rootCmd.Flags().BoolVar(&sequentialLaunch, "sequential-launch", defaultSequentialLaunch, "Launch MCP servers sequentially during startup (parallel launch is default)")
8184
rootCmd.Flags().CountVarP(&verbosity, "verbose", "v", "Increase verbosity level (use -v for info, -vv for debug, -vvv for trace)")
@@ -99,6 +102,15 @@ func getDefaultLogDir() string {
99102
return defaultLogDir
100103
}
101104

105+
// getDefaultPayloadDir returns the default payload directory, checking MCP_GATEWAY_PAYLOAD_DIR
106+
// environment variable first, then falling back to the hardcoded default
107+
func getDefaultPayloadDir() string {
108+
if envPayloadDir := os.Getenv("MCP_GATEWAY_PAYLOAD_DIR"); envPayloadDir != "" {
109+
return envPayloadDir
110+
}
111+
return defaultPayloadDir
112+
}
113+
102114
const (
103115
// Debug log patterns for different verbosity levels
104116
debugMainPackages = "cmd:*,server:*,launcher:*"
@@ -117,6 +129,11 @@ func registerFlagCompletions(cmd *cobra.Command) {
117129
return nil, cobra.ShellCompDirectiveFilterDirs
118130
})
119131

132+
// Custom completion for --payload-dir flag (complete with directories)
133+
cmd.RegisterFlagCompletionFunc("payload-dir", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
134+
return nil, cobra.ShellCompDirectiveFilterDirs
135+
})
136+
120137
// Custom completion for --env flag (complete with .env files)
121138
cmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
122139
return []string{"env"}, cobra.ShellCompDirectiveFilterFileExt
@@ -242,6 +259,7 @@ func run(cmd *cobra.Command, args []string) error {
242259
// Apply command-line flags to config
243260
cfg.EnableDIFC = enableDIFC
244261
cfg.SequentialLaunch = sequentialLaunch
262+
245263
if enableDIFC {
246264
log.Println("DIFC enforcement and session requirement enabled")
247265
} else {

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const (
2020
DefaultStartupTimeout = 60
2121
// DefaultToolTimeout is the default timeout for tool execution (seconds)
2222
DefaultToolTimeout = 120
23+
// DefaultPayloadDir is the default directory for storing large payload files
24+
DefaultPayloadDir = "/tmp/jq-payloads"
2325
)
2426

2527
// Config represents the MCPG configuration
@@ -37,6 +39,7 @@ type GatewayConfig struct {
3739
Domain string `toml:"domain"`
3840
StartupTimeout int `toml:"startup_timeout"` // Seconds
3941
ToolTimeout int `toml:"tool_timeout"` // Seconds
42+
PayloadDir string `toml:"payload_dir"` // Directory for storing large payload files
4043
}
4144

4245
// ServerConfig represents a single MCP server configuration
@@ -82,6 +85,7 @@ type StdinGatewayConfig struct {
8285
Domain string `json:"domain,omitempty"`
8386
StartupTimeout *int `json:"startupTimeout,omitempty"` // Seconds to wait for backend startup
8487
ToolTimeout *int `json:"toolTimeout,omitempty"` // Seconds to wait for tool execution
88+
PayloadDir string `json:"payloadDir,omitempty"` // Directory for storing large payload files
8589
}
8690

8791
// LoadFromFile loads configuration from a TOML file
@@ -115,6 +119,9 @@ func LoadFromFile(path string) (*Config, error) {
115119
if cfg.Gateway.Port == 0 {
116120
cfg.Gateway.Port = DefaultPort
117121
}
122+
if cfg.Gateway.PayloadDir == "" {
123+
cfg.Gateway.PayloadDir = DefaultPayloadDir
124+
}
118125
}
119126

120127
logConfig.Printf("Successfully loaded %d servers from TOML file", len(cfg.Servers))
@@ -185,6 +192,7 @@ func LoadFromStdin() (*Config, error) {
185192
Domain: stdinCfg.Gateway.Domain,
186193
StartupTimeout: DefaultStartupTimeout,
187194
ToolTimeout: DefaultToolTimeout,
195+
PayloadDir: DefaultPayloadDir,
188196
}
189197
if stdinCfg.Gateway.Port != nil {
190198
cfg.Gateway.Port = *stdinCfg.Gateway.Port
@@ -195,6 +203,9 @@ func LoadFromStdin() (*Config, error) {
195203
if stdinCfg.Gateway.ToolTimeout != nil {
196204
cfg.Gateway.ToolTimeout = *stdinCfg.Gateway.ToolTimeout
197205
}
206+
if stdinCfg.Gateway.PayloadDir != "" {
207+
cfg.Gateway.PayloadDir = stdinCfg.Gateway.PayloadDir
208+
}
198209
}
199210

200211
for name, server := range stdinCfg.MCPServers {

internal/config/config_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,39 @@ func TestLoadFromStdin_GatewayWithAllFields(t *testing.T) {
435435
assert.Equal(t, toolTimeout, *stdinCfg.Gateway.ToolTimeout, "Expected gateway toolTimeout")
436436
}
437437

438+
func TestLoadFromStdin_GatewayWithoutPayloadDir(t *testing.T) {
439+
jsonConfig := `{
440+
"mcpServers": {
441+
"test": {
442+
"type": "stdio",
443+
"container": "test/server:latest",
444+
"entrypointArgs": ["server.js"]
445+
}
446+
},
447+
"gateway": {
448+
"port": 8080,
449+
"apiKey": "test-key-123",
450+
"domain": "localhost"
451+
}
452+
}`
453+
454+
r, w, _ := os.Pipe()
455+
oldStdin := os.Stdin
456+
os.Stdin = r
457+
go func() {
458+
w.Write([]byte(jsonConfig))
459+
w.Close()
460+
}()
461+
462+
cfg, err := LoadFromStdin()
463+
os.Stdin = oldStdin
464+
465+
require.NoError(t, err, "LoadFromStdin() failed")
466+
require.NotNil(t, cfg, "Config should not be nil")
467+
require.NotNil(t, cfg.Gateway, "Gateway config should not be nil")
468+
assert.Equal(t, DefaultPayloadDir, cfg.Gateway.PayloadDir, "Expected default payload directory when not specified")
469+
}
470+
438471
func TestLoadFromStdin_ServerWithURL(t *testing.T) {
439472
jsonConfig := `{
440473
"mcpServers": {
@@ -917,6 +950,59 @@ args = ["run", "--rm", "-i", "test/container:latest"]
917950
assert.Equal(t, 60, cfg.Gateway.ToolTimeout)
918951
}
919952

953+
func TestLoadFromFile_WithGatewayPayloadDir(t *testing.T) {
954+
tmpDir := t.TempDir()
955+
tmpFile := filepath.Join(tmpDir, "config.toml")
956+
957+
tomlContent := `
958+
[gateway]
959+
port = 8080
960+
api_key = "test-key-123"
961+
domain = "localhost"
962+
payload_dir = "/custom/payload/path"
963+
964+
[servers.test]
965+
command = "docker"
966+
args = ["run", "--rm", "-i", "test/container:latest"]
967+
`
968+
969+
err := os.WriteFile(tmpFile, []byte(tomlContent), 0644)
970+
require.NoError(t, err, "Failed to write temp TOML file")
971+
972+
cfg, err := LoadFromFile(tmpFile)
973+
require.NoError(t, err, "LoadFromFile() failed")
974+
require.NotNil(t, cfg, "LoadFromFile() returned nil config")
975+
require.NotNil(t, cfg.Gateway, "Gateway config should not be nil")
976+
977+
assert.Equal(t, "/custom/payload/path", cfg.Gateway.PayloadDir, "Expected custom payload directory")
978+
}
979+
980+
func TestLoadFromFile_WithoutGatewayPayloadDir(t *testing.T) {
981+
tmpDir := t.TempDir()
982+
tmpFile := filepath.Join(tmpDir, "config.toml")
983+
984+
tomlContent := `
985+
[gateway]
986+
port = 8080
987+
api_key = "test-key-123"
988+
domain = "localhost"
989+
990+
[servers.test]
991+
command = "docker"
992+
args = ["run", "--rm", "-i", "test/container:latest"]
993+
`
994+
995+
err := os.WriteFile(tmpFile, []byte(tomlContent), 0644)
996+
require.NoError(t, err, "Failed to write temp TOML file")
997+
998+
cfg, err := LoadFromFile(tmpFile)
999+
require.NoError(t, err, "LoadFromFile() failed")
1000+
require.NotNil(t, cfg, "LoadFromFile() returned nil config")
1001+
require.NotNil(t, cfg.Gateway, "Gateway config should not be nil")
1002+
1003+
assert.Equal(t, DefaultPayloadDir, cfg.Gateway.PayloadDir, "Expected default payload directory when not specified")
1004+
}
1005+
9201006
func TestLoadFromFile_InvalidTOMLWithLineNumber(t *testing.T) {
9211007
tmpDir := t.TempDir()
9221008
tmpFile := filepath.Join(tmpDir, "config.toml")

internal/config/rules/rules.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,52 @@ func MountFormat(mount, jsonPath string, index int) *ValidationError {
187187

188188
return nil
189189
}
190+
191+
// NonEmptyString validates that a string field is not empty (minLength: 1)
192+
// Returns nil if valid, *ValidationError if invalid
193+
func NonEmptyString(value, fieldName, jsonPath string) *ValidationError {
194+
if value == "" {
195+
return &ValidationError{
196+
Field: fieldName,
197+
Message: fmt.Sprintf("%s cannot be empty", fieldName),
198+
JSONPath: jsonPath,
199+
Suggestion: fmt.Sprintf("Provide a non-empty value for %s", fieldName),
200+
}
201+
}
202+
return nil
203+
}
204+
205+
// AbsolutePath validates that a directory path is an absolute path
206+
// Per MCP Gateway schema: Unix paths start with '/', Windows paths start with a drive letter followed by ':\'
207+
// Pattern: ^(/|[A-Za-z]:\\)
208+
// Returns nil if valid, *ValidationError if invalid
209+
func AbsolutePath(value, fieldName, jsonPath string) *ValidationError {
210+
if value == "" {
211+
return &ValidationError{
212+
Field: fieldName,
213+
Message: fmt.Sprintf("%s cannot be empty", fieldName),
214+
JSONPath: jsonPath,
215+
Suggestion: fmt.Sprintf("Provide an absolute path for %s", fieldName),
216+
}
217+
}
218+
219+
// Check for Unix absolute path (starts with /)
220+
if strings.HasPrefix(value, "/") {
221+
return nil
222+
}
223+
224+
// Check for Windows absolute path (drive letter followed by :\)
225+
// Pattern: [A-Za-z]:\\
226+
if len(value) >= 3 &&
227+
((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) &&
228+
value[1] == ':' && value[2] == '\\' {
229+
return nil
230+
}
231+
232+
return &ValidationError{
233+
Field: fieldName,
234+
Message: fmt.Sprintf("%s must be an absolute path, got '%s'", fieldName, value),
235+
JSONPath: jsonPath,
236+
Suggestion: "Use an absolute path: Unix paths start with '/' (e.g., '/tmp/payloads'), Windows paths start with a drive letter (e.g., 'C:\\payloads')",
237+
}
238+
}

0 commit comments

Comments
 (0)