diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..79f6bbf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# Project overview +The ./README.md provides an overview of this project. + +@README.md + +## Potentially out-dated README.md contents + +** IMPORTANT ** +We are actively implementing new features in this project and have not updated all the docs yet. +Therefore the contents of the README.md as provided above are just for reference to provide +a good foundational understanding of the conceptual functionality of this project (or as the +project was a day or two ago). + +** Double-Check ** +For any work you may do, you must NOT consider the README.md contents as fully accurate, +up-to-date, or authoratative. + +Double-check and verify actual state before doing any work. + +You can also look at recent git commits. + +And also use claude-mem tool to get an excellent understanding of recent work, developments & plans. + +# Build Path + +`cd /path/to/mcp-debug` <---- replace this with actual path to the project. +(if already in the project root directory, the above command is unnecessary) + +go build -o ./bin/mcp-debug . + +## Build to ./bin/ + +Do not build/publish to ./ + +Only build/publish to ./bin/ + +(our MCP client is pointed at `./bin/mcp-debug`, so that is where you must create the build). diff --git a/DRAFT_ENV_INHERITANCE.md b/DRAFT_ENV_INHERITANCE.md new file mode 100644 index 0000000..ff411bb --- /dev/null +++ b/DRAFT_ENV_INHERITANCE.md @@ -0,0 +1,1205 @@ +# Environment Variable Inheritance (DRAFT) + +> **⚠️ DRAFT DOCUMENTATION**: This feature is fully implemented and tested but has not yet been validated with real-world MCP servers. Documentation may be updated based on real-world testing feedback. + +**Status**: Feature implemented in commit 49f5581 +**Branch**: feature/env-selective-inheritance +**Last Updated**: 2026-01-12 + +--- + +## Overview + +mcp-debug implements a **tier-based environment variable inheritance system** that provides fine-grained control over which environment variables are passed from the parent process to MCP server child processes. + +This feature addresses a critical security concern: preventing accidental leakage of sensitive environment variables (credentials, tokens, SSH agent sockets, API keys) to upstream MCP servers, especially experimental or third-party servers that may not have been thoroughly vetted. + +### Why This Matters + +When running MCP servers as child processes, the default behavior in most systems is to inherit all environment variables from the parent process. This can inadvertently expose: + +- **Cloud provider credentials** (AWS_ACCESS_KEY_ID, AZURE_CLIENT_SECRET, etc.) +- **Authentication tokens** (GITHUB_TOKEN, ANTHROPIC_API_KEY, etc.) +- **SSH agent sockets** (SSH_AUTH_SOCK) +- **Development secrets** (.env file variables) +- **Corporate credentials** loaded into your shell + +With selective inheritance, you explicitly control what gets shared, following the principle of least privilege. + +--- + +## Security Rationale + +### The Problem + +The traditional "inherit everything" approach using `os.Environ()` is convenient but dangerous: + +```yaml +servers: + - name: experimental-server + command: python3 + args: ["-m", "untrusted_mcp_server"] + # This server now has access to ALL your environment variables! +``` + +### The Solution + +Tier-based inheritance with explicit control: + +```yaml +servers: + - name: experimental-server + command: python3 + args: ["-m", "untrusted_mcp_server"] + inherit: + mode: tier1 # Only baseline variables + extra: ["PYTHONPATH"] # Explicitly add what's needed + deny: ["SSH_AUTH_SOCK"] # Explicitly block sensitive vars +``` + +### Security Benefits + +1. **Default-secure**: By default, only Tier 1 baseline variables are inherited +2. **Explicit opt-in**: Sensitive variables must be explicitly added via `extra` or `prefix` +3. **Auditable**: Configuration files show exactly what each server receives +4. **Defense in depth**: Multiple layers (tiers, deny lists, implicit blocks) +5. **httpoxy mitigation**: HTTP_PROXY (uppercase) blocked by default to prevent httpoxy attacks + +--- + +## Tier System + +The inheritance system is organized into two tiers plus an implicit denylist. + +### Tier 1: Baseline Variables + +These are minimal essential variables that most programs need to function correctly. Always inherited unless explicitly denied. + +| Variable | Purpose | +|----------|---------| +| `PATH` | Executable search path | +| `HOME` | User home directory | +| `USER` | Current username | +| `SHELL` | User's shell | +| `LANG` | Primary locale setting | +| `LC_ALL` | Locale override | +| `TZ` | Timezone | +| `TMPDIR` | Temporary directory (Unix) | +| `TEMP` | Temporary directory (Windows) | +| `TMP` | Temporary directory (Windows) | + +**Rationale**: These variables are required for basic process functionality and rarely contain secrets. Excluding them would break most servers. + +### Tier 2: Network and TLS Variables + +These are helpful for servers that make network connections or need TLS certificate validation. Inherited when `mode: tier1+tier2` or `mode: all` is set. + +| Variable | Purpose | +|----------|---------| +| `SSL_CERT_FILE` | Path to TLS certificate bundle | +| `SSL_CERT_DIR` | Directory containing TLS certificates | +| `REQUESTS_CA_BUNDLE` | Python requests library CA bundle | +| `CURL_CA_BUNDLE` | curl CA bundle path | +| `NODE_EXTRA_CA_CERTS` | Node.js additional CA certificates | + +**Rationale**: Enterprise environments often require custom CA bundles for TLS inspection/interception. These variables enable servers to validate certificates in corporate networks. + +**Note**: Proxy variables (http_proxy, https_proxy) are in the **implicit denylist** by default due to security concerns (see below). + +### Implicit Denylist + +These variables are **blocked by default** and require explicit opt-in via `extra` + `allow_denied_if_explicit: true`. + +| Variable | Reason | +|----------|--------| +| `HTTP_PROXY` | httpoxy vulnerability (uppercase variant) | +| `HTTPS_PROXY` | httpoxy vulnerability (uppercase variant) | +| `http_proxy` | Potential security risk | +| `https_proxy` | Potential security risk | +| `NO_PROXY` | Can leak internal network topology | +| `no_proxy` | Can leak internal network topology | + +**httpoxy vulnerability**: The uppercase `HTTP_PROXY` variable can be set by attackers via HTTP headers in CGI-like environments, causing the application to proxy requests through attacker-controlled servers. See [httpoxy.org](https://httpoxy.org/) for details. + +**Overriding the denylist**: If you genuinely need proxy variables, you must explicitly request them: + +```yaml +inherit: + mode: tier1+tier2 + extra: ["http_proxy", "https_proxy"] + allow_denied_if_explicit: true +``` + +--- + +## Inheritance Modes + +The `mode` setting controls the base set of variables to inherit. + +### `mode: none` + +**No automatic inheritance.** Only variables explicitly listed in `env:` are passed to the server. + +```yaml +servers: + - name: isolated-server + command: python3 + args: ["-m", "my_server"] + inherit: + mode: none + env: + # Only these exact variables will be set + PYTHONPATH: "/opt/myapp" + MY_CONFIG: "production" +``` + +**Use cases**: +- Maximum security/isolation +- Testing in controlled environments +- Containerized servers +- When you want complete control over the environment + +### `mode: tier1` (DEFAULT) + +**Inherit Tier 1 baseline variables only**, plus any variables from `extra` and `prefix`. + +```yaml +servers: + - name: python-server + command: python3 + args: ["-m", "my_server"] + inherit: + mode: tier1 # Can be omitted (it's the default) + extra: ["PYTHONPATH", "VIRTUAL_ENV"] +``` + +**Inherited**: PATH, HOME, USER, SHELL, LANG, LC_ALL, TZ, TMPDIR, TEMP, TMP + +**Use cases**: +- Default for most servers +- Good balance of functionality and security +- Prevents most secret leakage + +### `mode: tier1+tier2` + +**Inherit Tier 1 + Tier 2 variables**, plus any from `extra` and `prefix`. + +```yaml +servers: + - name: api-server + command: node + args: ["server.js"] + inherit: + mode: tier1+tier2 + extra: ["NODE_ENV"] +``` + +**Inherited**: All Tier 1 variables + SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS + +**Use cases**: +- Servers making HTTPS requests +- Enterprise environments with custom CA bundles +- Servers needing TLS certificate validation + +### `mode: all` + +**Inherit ALL environment variables from parent process**, except those in deny lists. + +```yaml +servers: + - name: trusted-server + command: ./my-trusted-app + inherit: + mode: all + deny: ["AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN"] +``` + +**Inherited**: Everything in parent environment (minus denied variables) + +**Use cases**: +- Trusted in-house servers +- Legacy servers requiring many variables +- Development environments where you want maximum compatibility + +**⚠️ Security Warning**: Use this mode only with fully trusted servers. Experimental or third-party servers should use `tier1` or `tier1+tier2`. + +--- + +## Configuration Options + +### Complete Schema + +```yaml +# Proxy-level defaults (optional) +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + +inherit: # Applied to all servers unless overridden + mode: "tier1" # none | tier1 | tier1+tier2 | all + extra: [] # Additional variable names + prefix: [] # Variable name prefixes to match + deny: [] # Variables to block + allow_denied_if_explicit: false # Allow denied vars if in 'extra' + +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "my_mcp_server"] + + # Server-specific inheritance (overrides proxy defaults) + inherit: + mode: "tier1+tier2" + extra: ["PYTHONPATH", "VIRTUAL_ENV"] + prefix: ["MY_APP_", "DATTO_"] + deny: ["SSH_AUTH_SOCK"] + allow_denied_if_explicit: true + + # Explicit overrides (always applied, never denied) + env: + MY_CONFIG: "production" + API_KEY: "${MY_API_KEY}" # Expanded from parent env +``` + +### Field Reference + +#### `mode` (string) + +Controls base inheritance behavior. + +- **Type**: String enum +- **Values**: `none`, `tier1`, `tier1+tier2`, `all` +- **Default**: `tier1` (if not specified) +- **Example**: `mode: "tier1+tier2"` + +#### `extra` (array of strings) + +Additional variable names to inherit beyond the tier definition. + +- **Type**: Array of strings +- **Default**: Empty array +- **Case-sensitive**: Variable names are matched case-sensitively on Unix, case-insensitively on Windows +- **Example**: `extra: ["PYTHONPATH", "VIRTUAL_ENV", "NODE_ENV"]` + +Variables listed in `extra` can bypass the implicit denylist if `allow_denied_if_explicit: true` is set. + +#### `prefix` (array of strings) + +Inherit all variables whose names start with these prefixes. + +- **Type**: Array of strings +- **Default**: Empty array +- **Case-sensitive**: Prefix matching follows platform conventions +- **Example**: `prefix: ["MY_APP_", "DATTO_", "CUSTOM_"]` + +Useful for inheriting groups of related variables (e.g., all configuration for a specific application). + +#### `deny` (array of strings) + +Variables to explicitly block from inheritance. + +- **Type**: Array of strings +- **Default**: Empty array +- **Combines with implicit denylist**: Both are applied +- **Example**: `deny: ["SSH_AUTH_SOCK", "AWS_SECRET_ACCESS_KEY"]` + +Use this to block sensitive variables even in `mode: all`. + +#### `allow_denied_if_explicit` (boolean) + +Allow variables from the implicit denylist if they're in `extra`. + +- **Type**: Boolean +- **Default**: `false` +- **Example**: `allow_denied_if_explicit: true` + +When `false`: Denied variables are always blocked, even if in `extra`. +When `true`: Variables in `extra` bypass both implicit and explicit deny lists. + +**Security note**: Only enable this if you understand the risks (e.g., httpoxy). + +--- + +## Configuration Examples + +### Example 1: Basic Python Server + +**Scenario**: Python MCP server needs Python-specific variables. + +```yaml +servers: + - name: python-mcp + transport: stdio + command: python3 + args: ["-m", "my_python_server"] + inherit: + mode: tier1 + extra: ["PYTHONPATH", "VIRTUAL_ENV", "PYTHONHOME"] +``` + +**Inherited**: +- Tier 1: PATH, HOME, USER, SHELL, LANG, LC_ALL, TZ, TMPDIR +- Extra: PYTHONPATH, VIRTUAL_ENV, PYTHONHOME + +### Example 2: Node.js Server with TLS + +**Scenario**: Node.js server making HTTPS API calls in corporate environment. + +```yaml +servers: + - name: node-api-server + transport: stdio + command: node + args: ["server.js"] + inherit: + mode: tier1+tier2 + extra: ["NODE_ENV", "NODE_OPTIONS"] +``` + +**Inherited**: +- Tier 1: PATH, HOME, USER, SHELL, LANG, LC_ALL, TZ, TMPDIR +- Tier 2: SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS +- Extra: NODE_ENV, NODE_OPTIONS + +### Example 3: Application-Specific Variables + +**Scenario**: Server needs all variables prefixed with `DATTO_` and `RMM_`. + +```yaml +servers: + - name: datto-rmm + transport: stdio + command: python3 + args: ["-m", "datto_rmm.server"] + inherit: + mode: tier1 + prefix: ["DATTO_", "RMM_"] + extra: ["PYTHONPATH"] +``` + +**Inherited**: +- Tier 1: PATH, HOME, USER, SHELL, etc. +- All variables starting with `DATTO_` (e.g., DATTO_API_KEY, DATTO_URL) +- All variables starting with `RMM_` +- PYTHONPATH + +### Example 4: Maximum Security + +**Scenario**: Untrusted experimental server, minimal exposure. + +```yaml +servers: + - name: experimental + transport: stdio + command: python3 + args: ["-m", "untrusted_server"] + inherit: + mode: tier1 + deny: ["SSH_AUTH_SOCK"] # Block SSH even though not in tier1 +``` + +**Inherited**: Only Tier 1 baseline variables + +### Example 5: Proxy with Corporate Proxy Variables + +**Scenario**: Enterprise environment requiring lowercase proxy variables. + +```yaml +servers: + - name: enterprise-server + transport: stdio + command: node + args: ["server.js"] + inherit: + mode: tier1+tier2 + extra: ["http_proxy", "https_proxy", "no_proxy"] + allow_denied_if_explicit: true # Override implicit denylist +``` + +**Inherited**: +- Tier 1 + Tier 2 variables +- http_proxy, https_proxy, no_proxy (despite being in implicit denylist) + +**⚠️ Security Note**: Only use this configuration if you control the server code and understand the httpoxy risk. + +### Example 6: Trusted Server with Deny List + +**Scenario**: In-house trusted server, inherit everything except specific secrets. + +```yaml +servers: + - name: trusted-internal + transport: stdio + command: ./internal-server + inherit: + mode: all + deny: + - AWS_SECRET_ACCESS_KEY + - AWS_SESSION_TOKEN + - GITHUB_TOKEN + - ANTHROPIC_API_KEY +``` + +**Inherited**: Everything except the four denied variables + +### Example 7: Proxy-Level Defaults + +**Scenario**: Set defaults for all servers, override for specific ones. + +```yaml +# Proxy-level defaults +proxy: + healthCheckInterval: "30s" + +inherit: + mode: tier1 + extra: ["LANG", "LC_ALL"] + +servers: + - name: basic-server + transport: stdio + command: python3 + args: ["-m", "basic_server"] + # Inherits proxy defaults (tier1 + LANG + LC_ALL) + + - name: special-server + transport: stdio + command: python3 + args: ["-m", "special_server"] + inherit: + mode: tier1+tier2 # Override: needs TLS + extra: ["PYTHONPATH", "API_KEY"] +``` + +--- + +## Default Behavior + +If you don't specify any inheritance configuration, the system uses secure defaults. + +### When No `inherit` Block Exists + +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] + # No inherit block specified +``` + +**Behavior**: Defaults to `mode: tier1` with no extras, prefixes, or deny rules. + +**Inherited**: PATH, HOME, USER, SHELL, LANG, LC_ALL, TZ, TMPDIR, TEMP, TMP + +### Completely Empty Configuration + +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] +``` + +**Behavior**: Same as above - defaults to `mode: tier1`. + +### Explicit Overrides Always Win + +Even with `mode: none`, explicit overrides in the `env:` block are always applied: + +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] + inherit: + mode: none # No inheritance + env: + CUSTOM_VAR: "value" # Always set + API_KEY: "${PARENT_API_KEY}" # Expanded from parent +``` + +**Result**: Only `CUSTOM_VAR` and `API_KEY` are set in the server environment. + +--- + +## Resolution Order + +Understanding precedence is crucial for debugging configuration issues. + +### Inheritance Resolution (Lowest to Highest Priority) + +1. **Implicit denylist** - HTTP_PROXY, HTTPS_PROXY, etc. blocked by default +2. **Tier 1 variables** - Always inherited unless denied +3. **Tier 2 variables** - Inherited if mode includes tier2 +4. **Proxy-level `inherit` config** - Default behavior for all servers +5. **Server-level `inherit` config** - Overrides proxy defaults +6. **Explicit `env:` overrides** - Always applied, never denied + +### Deny Resolution + +A variable is denied if: +- It's in the **implicit denylist** AND not in `extra` with `allow_denied_if_explicit: true` +- It's in the **proxy-level deny list** AND not in `extra` with `allow_denied_if_explicit: true` +- It's in the **server-level deny list** AND not in `extra` with `allow_denied_if_explicit: true` + +### Example Resolution + +```yaml +proxy: + inherit: + mode: tier1 + extra: ["PROXY_VAR"] + deny: ["BLOCKED_VAR"] + +servers: + - name: my-server + inherit: + mode: tier1+tier2 + extra: ["SERVER_VAR"] + # deny: [] (not specified, so proxy deny list applies) + env: + EXPLICIT_VAR: "value" +``` + +**Resolution**: +1. Start with Tier 1 (from server mode) +2. Add Tier 2 (from server mode: tier1+tier2) +3. Add PROXY_VAR (from proxy extra) +4. Add SERVER_VAR (from server extra) +5. Block BLOCKED_VAR (from proxy deny) +6. Block HTTP_PROXY, HTTPS_PROXY, etc. (implicit denylist) +7. Force set EXPLICIT_VAR (explicit override) + +--- + +## Implicit Denylist Details + +### Why Block HTTP_PROXY? + +The `HTTP_PROXY` environment variable (uppercase) is vulnerable to the **httpoxy** attack in CGI-like environments: + +1. Attacker sends HTTP request with `Proxy: evil.com:8080` header +2. CGI environment sets `HTTP_PROXY=evil.com:8080` +3. Application uses `HTTP_PROXY` to proxy all outbound requests +4. Attacker intercepts all traffic, stealing credentials and data + +**References**: +- [httpoxy.org](https://httpoxy.org/) +- [CVE-2016-5385](https://nvd.nist.gov/vuln/detail/CVE-2016-5385) + +### Lowercase vs Uppercase + +- **Lowercase** (`http_proxy`, `https_proxy`) - Standard libcurl convention, generally safer +- **Uppercase** (`HTTP_PROXY`, `HTTPS_PROXY`) - Vulnerable to httpoxy in CGI environments + +The implicit denylist blocks **both** out of an abundance of caution. If you need proxy support: + +```yaml +inherit: + extra: ["http_proxy", "https_proxy"] # Use lowercase variants + allow_denied_if_explicit: true +``` + +### Full Implicit Denylist + +``` +HTTP_PROXY +HTTPS_PROXY +http_proxy +https_proxy +NO_PROXY +no_proxy +``` + +--- + +## Troubleshooting + +### Server Can't Find Executables + +**Symptom**: Server fails with "command not found" errors. + +**Cause**: `PATH` not inherited (possible with `mode: none`). + +**Solution**: Ensure `PATH` is inherited or explicitly set: + +```yaml +inherit: + mode: tier1 # Includes PATH +# OR +inherit: + mode: none + extra: ["PATH"] +# OR +env: + PATH: "/usr/local/bin:/usr/bin:/bin" +``` + +### Server Can't Validate TLS Certificates + +**Symptom**: SSL certificate verification failures in corporate environment. + +**Cause**: Missing CA bundle environment variables. + +**Solution**: Use `tier1+tier2` mode or add SSL variables explicitly: + +```yaml +inherit: + mode: tier1+tier2 # Includes SSL_CERT_FILE, etc. +# OR +inherit: + mode: tier1 + extra: ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"] +``` + +### Proxy Variables Not Working + +**Symptom**: Server can't connect through corporate proxy. + +**Cause**: Proxy variables in implicit denylist. + +**Solution**: Explicitly allow lowercase proxy variables: + +```yaml +inherit: + extra: ["http_proxy", "https_proxy", "no_proxy"] + allow_denied_if_explicit: true +``` + +### Server Missing Application-Specific Variables + +**Symptom**: Server errors about missing configuration. + +**Cause**: Variables not in tier definitions. + +**Solution**: Use `extra` or `prefix`: + +```yaml +inherit: + mode: tier1 + extra: ["MY_API_KEY", "MY_CONFIG"] +# OR +inherit: + mode: tier1 + prefix: ["MY_APP_"] # Inherits MY_APP_KEY, MY_APP_URL, etc. +``` + +### Case Sensitivity Issues (Windows) + +**Symptom**: Environment variables not being inherited on Windows. + +**Cause**: Case mismatch between config and actual variable names. + +**Solution**: On Windows, environment variables are case-insensitive. The system will normalize keys automatically, but for consistency, match the case used in your environment: + +```yaml +# Windows: both work +inherit: + extra: ["PATH"] # Standard + extra: ["Path"] # Also works +``` + +### Checking What's Actually Inherited + +**Debug technique**: Create a test server that prints its environment: + +```yaml +servers: + - name: env-test + transport: stdio + command: /usr/bin/env # Unix + # command: cmd # Windows + # args: ["/c", "set"] # Windows + inherit: + mode: tier1 + extra: ["TEST_VAR"] +``` + +Run discovery or proxy mode and examine the output to see exactly what variables were inherited. + +--- + +## Validation + +mcp-debug validates inheritance configuration at startup. + +### Valid Modes + +```yaml +inherit: + mode: "none" # ✓ Valid + mode: "tier1" # ✓ Valid + mode: "tier1+tier2" # ✓ Valid + mode: "all" # ✓ Valid + mode: "" # ✓ Valid (defaults to tier1) +``` + +### Invalid Modes + +```yaml +inherit: + mode: "tier2" # ✗ Invalid (no tier2-only mode) + mode: "tier1,tier2" # ✗ Invalid (use "tier1+tier2") + mode: "some" # ✗ Invalid (not a defined mode) +``` + +### Validation Errors + +**Invalid mode**: +``` +Error: server 'my-server': inherit: invalid mode "tier2": must be one of: none, tier1, tier1+tier2, all +``` + +**Solution**: Fix the mode value in your configuration. + +### Running Validation + +```bash +# Validate config file +uvx mcp-debug config validate --config config.yaml + +# Test server startup +uvx mcp-debug --proxy --config config.yaml --log /tmp/debug.log +``` + +Check the log file for validation errors or warnings. + +--- + +## Platform Differences + +### Windows vs Unix + +**Environment Variable Names**: +- **Unix/Linux/macOS**: Case-sensitive (`PATH` ≠ `path`) +- **Windows**: Case-insensitive (`PATH` = `Path` = `path`) + +**Temporary Directories**: +- **Unix**: `TMPDIR` +- **Windows**: `TEMP`, `TMP` + +The tier system includes all variants for cross-platform compatibility. + +**File Paths in Values**: +- **Unix**: `/home/user/.config` +- **Windows**: `C:\Users\User\AppData\Roaming` + +Environment variable expansion respects platform path conventions. + +### XDG Base Directory Specification + +On Unix-like systems, Tier 1 includes XDG Base Directory variables per the [freedesktop.org specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html): + +- `XDG_CONFIG_HOME` - User configuration files +- `XDG_CACHE_HOME` - User cache data +- `XDG_DATA_HOME` - User data files +- `XDG_STATE_HOME` - User state data +- `XDG_RUNTIME_DIR` - Runtime files and sockets + +These are not applicable on Windows. + +--- + +## Advanced Use Cases + +### Multi-Tenant Isolation + +**Scenario**: Running multiple MCP servers for different customers, ensuring complete isolation. + +```yaml +servers: + - name: customer-a + transport: stdio + command: python3 + args: ["-m", "mcp_server"] + inherit: + mode: none + env: + CUSTOMER_ID: "customer-a" + DB_URL: "postgresql://db-a/data" + + - name: customer-b + transport: stdio + command: python3 + args: ["-m", "mcp_server"] + inherit: + mode: none + env: + CUSTOMER_ID: "customer-b" + DB_URL: "postgresql://db-b/data" +``` + +Each server has a completely isolated environment. + +### Development vs Production + +**Scenario**: Different inheritance rules for dev and production. + +```yaml +# development-config.yaml +proxy: + inherit: + mode: all # Relaxed for development + deny: [] + +servers: + - name: dev-server + transport: stdio + command: ./dev-server +``` + +```yaml +# production-config.yaml +proxy: + inherit: + mode: tier1 # Strict for production + deny: ["SSH_AUTH_SOCK"] + +servers: + - name: prod-server + transport: stdio + command: ./prod-server +``` + +### Dynamic Environment Variables + +**Scenario**: Pass current timestamp or dynamic values to servers. + +```bash +# Set variable in parent shell +export BUILD_ID="$(date +%s)" +export DEPLOY_VERSION="v1.2.3" +``` + +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] + inherit: + mode: tier1 + extra: ["BUILD_ID", "DEPLOY_VERSION"] +``` + +The server receives the current values of these variables. + +### Testing Different Inheritance Modes + +**Scenario**: A/B testing different inheritance configurations. + +Create multiple config files and test: + +```bash +# Test tier1 (minimal) +uvx mcp-debug --proxy --config config-tier1.yaml + +# Test tier1+tier2 (with network) +uvx mcp-debug --proxy --config config-tier2.yaml + +# Test all (maximum compatibility) +uvx mcp-debug --proxy --config config-all.yaml +``` + +Compare behavior and choose the most secure option that works. + +--- + +## Migration Guide + +### From No Configuration + +If you previously ran mcp-debug without any inheritance configuration: + +**Old behavior**: Depended on implementation details (likely all variables inherited) + +**New behavior**: Defaults to `mode: tier1` (secure by default) + +**Migration path**: +1. Test with default `tier1` mode +2. If servers break, identify missing variables via logs +3. Add missing variables to `extra` list +4. OR switch to `mode: all` temporarily, then gradually restrict + +### From `mode: all` + +If you started with `mode: all` and want to tighten security: + +**Step 1**: Enable debug logging to see what variables servers actually use: + +```yaml +inherit: + mode: all + # Add logging to see what's accessed (implementation-dependent) +``` + +**Step 2**: Switch to `tier1+tier2` and add known requirements: + +```yaml +inherit: + mode: tier1+tier2 + extra: ["VARIABLE1", "VARIABLE2"] +``` + +**Step 3**: Test thoroughly and add missing variables as needed. + +**Step 4**: Once stable, consider switching to `tier1` if tier2 isn't needed. + +### Adding to Existing Servers + +If you have existing server configurations without inheritance: + +**Before**: +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] +``` + +**After**: +```yaml +servers: + - name: my-server + transport: stdio + command: python3 + args: ["-m", "server"] + inherit: + mode: tier1 + extra: ["PYTHONPATH"] +``` + +The default behavior (tier1) is applied automatically if you don't add an `inherit` block. + +--- + +## Testing Your Configuration + +### Manual Testing + +**Step 1**: Create a test server that prints its environment: + +```yaml +# test-config.yaml +servers: + - name: env-dump + transport: stdio + command: /usr/bin/env + inherit: + mode: tier1 + extra: ["TEST_VAR"] +``` + +**Step 2**: Set test variables: + +```bash +export TEST_VAR="test-value" +export SECRET_VAR="should-not-appear" +``` + +**Step 3**: Run mcp-debug: + +```bash +uvx mcp-debug --proxy --config test-config.yaml +``` + +**Step 4**: Check output - `TEST_VAR` should appear, `SECRET_VAR` should not. + +### Automated Testing + +Create a test script: + +```bash +#!/bin/bash + +export TIER1_VAR="HOME" # Should inherit +export TIER2_VAR="SSL_CERT_FILE" # Only with tier2 +export CUSTOM_VAR="test" # Only with extra +export SECRET_VAR="secret" # Should NOT inherit + +# Test tier1 mode +echo "Testing tier1 mode..." +uvx mcp-debug --proxy --config test-tier1.yaml 2>&1 | grep -q "HOME=" +if [ $? -eq 0 ]; then echo "✓ Tier1 works"; else echo "✗ Tier1 failed"; fi + +# Test tier1+tier2 mode +echo "Testing tier1+tier2 mode..." +uvx mcp-debug --proxy --config test-tier2.yaml 2>&1 | grep -q "SSL_CERT_FILE=" +if [ $? -eq 0 ]; then echo "✓ Tier2 works"; else echo "✗ Tier2 failed"; fi + +# Verify secret not leaked +echo "Testing secret isolation..." +uvx mcp-debug --proxy --config test-tier1.yaml 2>&1 | grep -q "SECRET_VAR=" +if [ $? -eq 1 ]; then echo "✓ Secret blocked"; else echo "✗ Secret leaked!"; fi +``` + +### Integration Testing + +Test with real MCP servers: + +```yaml +servers: + - name: real-server + transport: stdio + command: python3 + args: ["-m", "my_real_server"] + inherit: + mode: tier1 + extra: ["PYTHONPATH"] +``` + +Run through normal workflows and verify: +1. Server starts successfully +2. Tools work as expected +3. No errors about missing environment variables +4. Sensitive variables are not accessible to the server + +--- + +## FAQ + +### Q: What happens if I don't specify an `inherit` block? + +**A**: Defaults to `mode: tier1` (secure by default). Only baseline variables are inherited. + +### Q: Can I use `mode: tier2` without `tier1`? + +**A**: No. The only modes are `none`, `tier1`, `tier1+tier2`, and `all`. Tier 2 always includes Tier 1. + +### Q: How do I inherit ALL variables like the old behavior? + +**A**: Use `mode: all`: + +```yaml +inherit: + mode: all +``` + +### Q: Why are proxy variables blocked by default? + +**A**: To prevent httpoxy attacks. The uppercase `HTTP_PROXY` variable can be set by attackers via HTTP headers in CGI environments. We block both uppercase and lowercase variants out of caution. + +### Q: How do I enable proxy variables safely? + +**A**: Use lowercase variants with explicit opt-in: + +```yaml +inherit: + mode: tier1+tier2 + extra: ["http_proxy", "https_proxy", "no_proxy"] + allow_denied_if_explicit: true +``` + +### Q: Does `env:` override the deny list? + +**A**: Yes. Explicit `env:` overrides are ALWAYS applied, regardless of deny lists. + +```yaml +inherit: + deny: ["BLOCKED_VAR"] +env: + BLOCKED_VAR: "this-will-be-set" # Always wins +``` + +### Q: Can I mix `prefix` and `extra`? + +**A**: Yes. They're additive: + +```yaml +inherit: + extra: ["CUSTOM1", "CUSTOM2"] + prefix: ["MY_APP_", "CONFIG_"] +``` + +This inherits: CUSTOM1, CUSTOM2, plus any variables starting with MY_APP_ or CONFIG_. + +### Q: Are environment variable names case-sensitive? + +**A**: On Unix: yes (`PATH` ≠ `path`). On Windows: no (`PATH` = `Path` = `path`). + +### Q: Can I set proxy-level defaults and override per-server? + +**A**: Yes: + +```yaml +proxy: + inherit: + mode: tier1 # Default for all servers + +servers: + - name: server1 + inherit: + mode: tier1+tier2 # Override for this server +``` + +### Q: What if a variable is in both `extra` and `deny`? + +**A**: Depends on `allow_denied_if_explicit`: +- `false` (default): Variable is blocked +- `true`: Variable is allowed (because it's in `extra`) + +### Q: How do I debug what's being inherited? + +**A**: Use a test server that prints its environment: + +```yaml +servers: + - name: env-test + command: /usr/bin/env + inherit: + mode: tier1 +``` + +### Q: Can I use environment variable expansion in the `inherit` block? + +**A**: Yes, for the lists: + +```yaml +inherit: + extra: ["${MY_VAR_NAME}"] # Expands at config load time +``` + +But this is rarely useful. More commonly, you'd use expansion in `env:`: + +```yaml +env: + API_KEY: "${PARENT_API_KEY}" # Gets value from parent +``` + +### Q: Does this work with SSE (Server-Sent Events) transport? + +**A**: The inheritance system only applies to `stdio` transport (local child processes). SSE and HTTP transports don't have environment inheritance because they're remote connections. + +--- + +## See Also + +- [Main README](README.md) - mcp-debug overview +- [Configuration Examples](examples/) - Sample config files +- [MCP Specification](https://modelcontextprotocol.io/) - Model Context Protocol docs +- [httpoxy.org](https://httpoxy.org/) - httpoxy vulnerability details +- [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) - XDG spec + +--- + +## Feedback and Updates + +This is DRAFT documentation for a newly implemented feature. As we gather real-world usage data, we may: + +- Adjust tier definitions based on common requirements +- Add new configuration options +- Update security recommendations +- Add more examples and troubleshooting guidance + +**Report issues or suggest improvements**: +- GitHub Issues: [mcp-debug issues](https://github.com/standardbeagle/mcp-debug/issues) +- Discussion: Include "[env-inheritance]" in the title + +**Last Updated**: 2026-01-12 +**Implementation Commit**: 49f5581 +**Branch**: feature/env-selective-inheritance diff --git a/README.md b/README.md index 300f61e..0efdd91 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ MCP Debug enables rapid development and testing of MCP servers with hot-swapping ### Session Recording & Playback - Record JSON-RPC traffic for debugging and documentation +- Records all tool calls (static and dynamic servers) +- Records management operations (server_add, etc.) - Playback client mode - replay requests to test servers - Playback server mode - replay responses to test clients - Regression testing with recorded sessions +- **[📖 Recording Documentation](docs/RECORDING.md)** - Complete recording guide ### Development Proxy - Multi-server aggregation with tool prefixing @@ -89,6 +92,8 @@ uvx mcp-debug --playback-client session.jsonl | ./your-mcp-server mcp-tui uvx mcp-debug --playback-server session.jsonl ``` +**See [Recording Documentation](docs/RECORDING.md) for detailed recording format, workflows, and examples.** + ## Configuration ```yaml @@ -116,6 +121,66 @@ MCP_RECORD_FILE="session.jsonl" # Auto-record sessions MCP_CONFIG_PATH="./config.yaml" # Default config ``` +## Environment Variable Inheritance (DRAFT) + +> **⚠️ DRAFT**: This feature is fully implemented but not yet validated with real-world MCP servers. See [DRAFT_ENV_INHERITANCE.md](DRAFT_ENV_INHERITANCE.md) for complete documentation. + +mcp-debug implements a tier-based environment variable inheritance system to prevent accidental leakage of sensitive values (credentials, tokens, SSH agent sockets) to upstream MCP servers. + +### Security-First Design + +By default, only **Tier 1 baseline variables** (PATH, HOME, USER, SHELL, locale, TMPDIR) are inherited. This prevents inadvertent exposure of: +- Cloud provider credentials (AWS_ACCESS_KEY_ID, AZURE_CLIENT_SECRET) +- Authentication tokens (GITHUB_TOKEN, ANTHROPIC_API_KEY) +- SSH agent sockets (SSH_AUTH_SOCK) +- Development secrets and corporate credentials + +You can control inheritance behavior per-server or set proxy-wide defaults. + +### Quick Example + +```yaml +# Secure by default - only baseline variables +servers: + - name: untrusted-server + transport: stdio + command: python3 + args: ["-m", "experimental_server"] + # No inherit block = tier1 mode (secure default) + +# Explicit control for trusted servers + - name: trusted-server + transport: stdio + command: python3 + args: ["-m", "my_server"] + inherit: + mode: tier1+tier2 # Add network/TLS variables + extra: ["PYTHONPATH", "VIRTUAL_ENV"] # Explicitly add needed vars + prefix: ["MY_APP_"] # Include all MY_APP_* variables + deny: ["SSH_AUTH_SOCK"] # Block specific variables +``` + +### Inheritance Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| `none` | No inheritance (only explicit `env:` values) | Maximum isolation | +| `tier1` | Baseline variables only (DEFAULT) | Most servers | +| `tier1+tier2` | Baseline + network/TLS variables | Servers making HTTPS requests | +| `all` | Inherit everything (with optional deny list) | Fully trusted servers | + +### Tier Definitions + +**Tier 1 (Baseline)**: PATH, HOME, USER, SHELL, LANG, LC_ALL, TZ, TMPDIR, TEMP, TMP + +**Tier 2 (Network/TLS)**: SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS + +**Implicit Denylist**: HTTP_PROXY, HTTPS_PROXY, http_proxy, https_proxy, NO_PROXY, no_proxy (httpoxy mitigation) + +### Complete Documentation + +For complete documentation including all configuration options, security rationale, troubleshooting, and advanced use cases, see [DRAFT_ENV_INHERITANCE.md](DRAFT_ENV_INHERITANCE.md). + ## Development Workflow ```bash diff --git a/client/env.go b/client/env.go new file mode 100644 index 0000000..4c4b049 --- /dev/null +++ b/client/env.go @@ -0,0 +1,72 @@ +package client + +import ( + "os" + "runtime" + "strings" +) + +// MergeEnvironment merges the parent process environment with overrides. +// The parent environment is obtained via os.Environ() and then override +// values are applied on top. On Windows, environment variable names are +// case-insensitive and normalized to uppercase for proper deduplication. +// +// Returns a []string in "KEY=value" format suitable for exec.Cmd.Env. +func MergeEnvironment(overrides map[string]string) []string { + isWindows := runtime.GOOS == "windows" + + // Maps for tracking environment variables + // envMap: normalized_key -> value + // keyMap: normalized_key -> original_key (preserves casing) + envMap := make(map[string]string) + keyMap := make(map[string]string) + + // Load parent environment + for _, entry := range os.Environ() { + key, value := splitEnvEntry(entry) + if key == "" { + continue // Skip malformed entries + } + + lookupKey := normalizeKey(key, isWindows) + envMap[lookupKey] = value + keyMap[lookupKey] = key + } + + // Apply overrides (last wins) + for key, value := range overrides { + lookupKey := normalizeKey(key, isWindows) + envMap[lookupKey] = value + keyMap[lookupKey] = key // Use override's exact casing + } + + // Build result slice + result := make([]string, 0, len(envMap)) + for lookupKey, value := range envMap { + originalKey := keyMap[lookupKey] + result = append(result, originalKey+"="+value) + } + + return result +} + +// normalizeKey normalizes environment variable keys for comparison. +// On Windows, converts to uppercase for case-insensitive comparison. +// On other platforms, returns the key unchanged. +func normalizeKey(key string, isWindows bool) string { + if isWindows { + return strings.ToUpper(key) + } + return key +} + +// splitEnvEntry splits an environment entry "KEY=value" into key and value. +// Handles values containing "=" by only splitting on the first occurrence. +// Returns empty string for key if the entry is malformed. +func splitEnvEntry(entry string) (key, value string) { + idx := strings.Index(entry, "=") + if idx <= 0 { + return "", "" // Malformed entry + } + return entry[:idx], entry[idx+1:] +} diff --git a/client/env_test.go b/client/env_test.go new file mode 100644 index 0000000..d75534a --- /dev/null +++ b/client/env_test.go @@ -0,0 +1,238 @@ +package client + +import ( + "os" + "runtime" + "testing" +) + +// TestMergeEnvironment tests the MergeEnvironment function with various scenarios +func TestMergeEnvironment(t *testing.T) { + tests := []struct { + name string + parent []string + overrides map[string]string + expected map[string]string + }{ + { + name: "empty overrides", + parent: []string{"PATH=/usr/bin", "HOME=/home/user"}, + overrides: map[string]string{}, + expected: map[string]string{ + "PATH": "/usr/bin", + "HOME": "/home/user", + }, + }, + { + name: "override existing variable", + parent: []string{"PATH=/usr/bin", "HOME=/home/user"}, + overrides: map[string]string{ + "PATH": "/custom/bin", + }, + expected: map[string]string{ + "PATH": "/custom/bin", + "HOME": "/home/user", + }, + }, + { + name: "add new variable", + parent: []string{"PATH=/usr/bin"}, + overrides: map[string]string{ + "DEBUG": "true", + }, + expected: map[string]string{ + "PATH": "/usr/bin", + "DEBUG": "true", + }, + }, + { + name: "mixed override and add", + parent: []string{"PATH=/usr/bin", "HOME=/home/user"}, + overrides: map[string]string{ + "PATH": "/custom/bin", + "DEBUG": "true", + }, + expected: map[string]string{ + "PATH": "/custom/bin", + "HOME": "/home/user", + "DEBUG": "true", + }, + }, + { + name: "empty string value", + parent: []string{"PATH=/usr/bin"}, + overrides: map[string]string{ + "PATH": "", + }, + expected: map[string]string{ + "PATH": "", + }, + }, + { + name: "nil parent", + parent: nil, + overrides: map[string]string{ + "DEBUG": "true", + }, + expected: map[string]string{ + "DEBUG": "true", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Temporarily set system env to match parent for testing + if tt.parent != nil { + // Clear env and set to parent values for this test + oldEnv := os.Environ() + defer func() { + // Restore original env + os.Clearenv() + for _, entry := range oldEnv { + key, value := splitEnvEntry(entry) + if key != "" { + os.Setenv(key, value) + } + } + }() + + os.Clearenv() + for _, entry := range tt.parent { + key, value := splitEnvEntry(entry) + if key != "" { + os.Setenv(key, value) + } + } + } + + result := MergeEnvironment(tt.overrides) + resultMap := sliceToMap(result) + + // If parent is nil, merge with system environment + if tt.parent == nil { + // Just check that our override is present + if val, ok := resultMap["DEBUG"]; !ok || val != "true" { + t.Errorf("Expected DEBUG=true in result, got %v", resultMap) + } + return + } + + // Check all expected variables are present with correct values + for key, expectedVal := range tt.expected { + if actualVal, ok := resultMap[key]; !ok { + t.Errorf("Expected key %q not found in result", key) + } else if actualVal != expectedVal { + t.Errorf("For key %q: expected %q, got %q", key, expectedVal, actualVal) + } + } + + // Check no unexpected variables (only for non-nil parent) + if len(resultMap) != len(tt.expected) { + t.Errorf("Expected %d variables, got %d: %v", len(tt.expected), len(resultMap), resultMap) + } + }) + } +} + +// TestMergeEnvironmentWindows tests Windows-specific case-insensitive behavior +func TestMergeEnvironmentWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Skipping Windows-specific test") + } + + // Save and restore environment + defer restoreEnv("PATH")() + + // Set up test environment + oldEnv := os.Environ() + defer func() { + // Restore original env + os.Clearenv() + for _, entry := range oldEnv { + key, value := splitEnvEntry(entry) + if key != "" { + os.Setenv(key, value) + } + } + }() + + os.Clearenv() + os.Setenv("PATH", "C:\\Windows\\System32") + os.Setenv("HOME", "C:\\Users\\test") + + overrides := map[string]string{ + "Path": "C:\\Custom\\Path", // Different case + } + + result := MergeEnvironment(overrides) + resultMap := sliceToMap(result) + + // On Windows, PATH and Path should be treated as the same variable + pathValue := "" + for key, val := range resultMap { + if key == "PATH" || key == "Path" || key == "path" { + pathValue = val + break + } + } + + if pathValue != "C:\\Custom\\Path" { + t.Errorf("Expected PATH to be overridden (case-insensitive), got: %v", resultMap) + } + + // HOME should still be present + if home, ok := resultMap["HOME"]; !ok || home != "C:\\Users\\test" { + t.Errorf("Expected HOME=C:\\Users\\test, got: %v", resultMap) + } +} + +// restoreEnv saves the current environment variable and returns a function to restore it +func restoreEnv(key string) func() { + original, exists := os.LookupEnv(key) + return func() { + if exists { + os.Setenv(key, original) + } else { + os.Unsetenv(key) + } + } +} + +// TestSplitEnvEntry tests parsing of environment variable entries +func TestSplitEnvEntry(t *testing.T) { + tests := []struct { + name string + entry string + wantKey string + wantValue string + }{ + {"normal entry", "PATH=/usr/bin", "PATH", "/usr/bin"}, + {"value with equals", "URL=http://example.com?foo=bar", "URL", "http://example.com?foo=bar"}, + {"empty value", "EMPTY=", "EMPTY", ""}, + {"no equals", "INVALID", "", ""}, + {"empty key", "=value", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value := splitEnvEntry(tt.entry) + if key != tt.wantKey || value != tt.wantValue { + t.Errorf("splitEnvEntry(%q) = (%q, %q), want (%q, %q)", + tt.entry, key, value, tt.wantKey, tt.wantValue) + } + }) + } +} + +// sliceToMap converts []string environment to map[string]string for easier testing +func sliceToMap(env []string) map[string]string { + result := make(map[string]string) + for _, entry := range env { + key, value := splitEnvEntry(entry) + if key != "" { + result[key] = value + } + } + return result +} diff --git a/client/envbuilder.go b/client/envbuilder.go new file mode 100644 index 0000000..655ca9a --- /dev/null +++ b/client/envbuilder.go @@ -0,0 +1,249 @@ +package client + +import ( + "os" + "runtime" + "strings" + + "mcp-debug/config" +) + +// Tier1Vars are baseline environment variables that most processes need. +// These are always inherited unless explicitly denied. +var Tier1Vars = []string{ + "PATH", + "HOME", + "USER", + "SHELL", + "LANG", + "LC_ALL", + "TZ", + "TMPDIR", + "TEMP", + "TMP", +} + +// Tier2Vars are network and TLS-related variables. +// These are inherited when TLS inheritance is enabled. +var Tier2Vars = []string{ + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +} + +// ImplicitDenylist contains variables that should never be inherited +// without explicit configuration, as they can cause unexpected behavior. +var ImplicitDenylist = []string{ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", +} + +// BuildEnvironment constructs the environment for an MCP server based on +// tier-based inheritance rules and configuration overrides. +// +// Inheritance tiers: +// - Tier 1 (baseline): Always inherited unless explicitly denied +// - Tier 2 (network/TLS): Inherited when TLS inheritance enabled +// - Implicit denylist: Blocked by default (e.g., HTTP_PROXY) +// - Extra variables: Additional variables specified in config +// - Prefix matching: Variables matching configured prefixes +// +// Configuration precedence (highest to lowest): +// 1. Explicit env overrides in server config +// 2. Explicit deny rules (server and proxy level) +// 3. Tier 1 variables (unless denied) +// 4. Tier 2 variables (if TLS enabled, unless denied) +// 5. Extra variables from config (unless denied) +// 6. Prefix-matched variables (unless denied) +// +// Parameters: +// - serverConfig: The server configuration containing env overrides and inheritance rules +// - proxyInherit: Proxy-level inheritance configuration (may be nil) +// +// Returns: +// - []string: Environment in "KEY=value" format for exec.Cmd.Env +func BuildEnvironment(serverConfig *config.ServerConfig, proxyInherit *config.InheritConfig) []string { + isWindows := runtime.GOOS == "windows" + + // Build combined deny map (normalized keys) + denyMap := buildDenyMap(serverConfig, proxyInherit, isWindows) + + // Build parent environment map (normalized lookup keys) + parentMap := buildParentMap() + + // Result map: normalized_key -> (original_key, value) + envMap := make(map[string]struct { + key string + value string + }) + + // Helper to add variable if not denied + // explicitExtra indicates if this is from the Extra list (bypasses implicit deny) + addVar := func(key string, explicitExtra bool) { + lookupKey := normalizeKey(key, isWindows) + + // Check if denied + if denyMap[lookupKey] { + // If this is from Extra list and AllowDeniedIfExplicit is true, allow it + if explicitExtra { + if serverConfig.Inherit != nil && serverConfig.Inherit.AllowDeniedIfExplicit { + // Allow this variable even though it's denied + } else if proxyInherit != nil && proxyInherit.AllowDeniedIfExplicit { + // Allow this variable even though it's denied + } else { + return // Denied and not explicitly allowed + } + } else { + return // Explicitly denied + } + } + + if val, exists := parentMap[lookupKey]; exists { + envMap[lookupKey] = struct { + key string + value string + }{key, val} + } + } + + // Step 1: Add Tier 1 (baseline) variables + for _, key := range Tier1Vars { + addVar(key, false) + } + + // Step 2: Add Tier 2 (network/TLS) variables if tier1+tier2 or all mode enabled + tier2Enabled := false + if serverConfig.Inherit != nil { + if serverConfig.Inherit.Mode == config.InheritTier1Tier2 || serverConfig.Inherit.Mode == config.InheritAll { + tier2Enabled = true + } + } + if !tier2Enabled && proxyInherit != nil { + if proxyInherit.Mode == config.InheritTier1Tier2 || proxyInherit.Mode == config.InheritAll { + tier2Enabled = true + } + } + if tier2Enabled { + for _, key := range Tier2Vars { + addVar(key, false) + } + } + + // Step 3: Add extra variables from config (server level, then proxy level) + if serverConfig.Inherit != nil { + for _, key := range serverConfig.Inherit.Extra { + addVar(key, true) // Mark as explicit extra + } + } + if proxyInherit != nil { + for _, key := range proxyInherit.Extra { + addVar(key, true) // Mark as explicit extra + } + } + + // Step 4: Add prefix-matched variables (server level, then proxy level) + prefixes := []string{} + if serverConfig.Inherit != nil { + prefixes = append(prefixes, serverConfig.Inherit.Prefix...) + } + if proxyInherit != nil { + prefixes = append(prefixes, proxyInherit.Prefix...) + } + + for lookupKey, val := range parentMap { + if denyMap[lookupKey] { + continue // Already denied + } + // Check if any prefix matches + for _, prefix := range prefixes { + normalizedPrefix := normalizeKey(prefix, isWindows) + if strings.HasPrefix(lookupKey, normalizedPrefix) { + // Find original key from parent environment + originalKey := "" + for _, entry := range os.Environ() { + k, v := splitEnvEntry(entry) + if normalizeKey(k, isWindows) == lookupKey && v == val { + originalKey = k + break + } + } + if originalKey != "" { + envMap[lookupKey] = struct { + key string + value string + }{originalKey, val} + } + break + } + } + } + + // Step 5: Apply explicit environment overrides from server config + // These override everything and ignore deny rules + for key, value := range serverConfig.Env { + lookupKey := normalizeKey(key, isWindows) + envMap[lookupKey] = struct { + key string + value string + }{key, value} + } + + // Build final result + result := make([]string, 0, len(envMap)) + for _, entry := range envMap { + result = append(result, entry.key+"="+entry.value) + } + + return result +} + +// buildDenyMap creates a normalized map of denied variable names. +// Includes implicit denylist plus any explicit deny rules from config. +func buildDenyMap(serverConfig *config.ServerConfig, proxyInherit *config.InheritConfig, isWindows bool) map[string]bool { + denyMap := make(map[string]bool) + + // Add implicit denylist + for _, key := range ImplicitDenylist { + denyMap[normalizeKey(key, isWindows)] = true + } + + // Add server-level deny rules + if serverConfig.Inherit != nil { + for _, key := range serverConfig.Inherit.Deny { + denyMap[normalizeKey(key, isWindows)] = true + } + } + + // Add proxy-level deny rules + if proxyInherit != nil { + for _, key := range proxyInherit.Deny { + denyMap[normalizeKey(key, isWindows)] = true + } + } + + return denyMap +} + +// buildParentMap creates a normalized map of parent environment variables. +// Returns: map[normalized_key]value +func buildParentMap() map[string]string { + isWindows := runtime.GOOS == "windows" + parentMap := make(map[string]string) + + for _, entry := range os.Environ() { + key, value := splitEnvEntry(entry) + if key == "" { + continue + } + lookupKey := normalizeKey(key, isWindows) + parentMap[lookupKey] = value + } + + return parentMap +} diff --git a/client/envbuilder_test.go b/client/envbuilder_test.go new file mode 100644 index 0000000..dff9159 --- /dev/null +++ b/client/envbuilder_test.go @@ -0,0 +1,573 @@ +package client + +import ( + "os" + "runtime" + "strings" + "testing" + + "mcp-debug/config" +) + +// TestBuildEnvironment_ModeNone tests that only explicit overrides are included +func TestBuildEnvironment_ModeNone(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("SECRET_KEY", "should-not-inherit") + + // Create server config with mode=none and explicit overrides + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritNone, + }, + Env: map[string]string{ + "CUSTOM": "value", + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify override is present + if resultMap["CUSTOM"] != "value" { + t.Error("CUSTOM override should be present") + } + + // Verify Tier1 vars ARE inherited (Tier1 is baseline, always inherited unless denied) + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited (Tier1 baseline)") + } + if _, ok := resultMap["PATH"]; !ok { + t.Error("PATH should be inherited (Tier1 baseline)") + } + + // Verify non-tier1 var is NOT present + if _, ok := resultMap["SECRET_KEY"]; ok { + t.Error("SECRET_KEY should NOT be inherited in mode=none") + } +} + +// TestBuildEnvironment_ModeTier1 tests that only Tier1 vars are inherited +func TestBuildEnvironment_ModeTier1(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("USER", "testuser") + os.Setenv("SHELL", "/bin/bash") + os.Setenv("SECRET_KEY", "should-not-inherit") + os.Setenv("SSH_AUTH_SOCK", "/tmp/ssh-agent") + + // Create server config with tier1 mode and explicit overrides + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + }, + Env: map[string]string{ + "CUSTOM": "value", + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify tier1 vars are present + tier1Expected := []string{"HOME", "PATH", "USER", "SHELL"} + for _, varName := range tier1Expected { + if _, ok := resultMap[varName]; !ok { + t.Errorf("%s should be inherited (tier1 var)", varName) + } + } + + // Verify non-tier1 vars are NOT present + if _, ok := resultMap["SECRET_KEY"]; ok { + t.Error("SECRET_KEY should NOT be inherited (not in tier1)") + } + if _, ok := resultMap["SSH_AUTH_SOCK"]; ok { + t.Error("SSH_AUTH_SOCK should NOT be inherited (not in tier1)") + } + + // Verify explicit override is present + if resultMap["CUSTOM"] != "value" { + t.Error("CUSTOM override should be present") + } +} + +// TestBuildEnvironment_ModeTier1Tier2 tests that Tier1 + Tier2 vars are inherited +func TestBuildEnvironment_ModeTier1Tier2(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("SSL_CERT_FILE", "/etc/ssl/cert.pem") + os.Setenv("CURL_CA_BUNDLE", "/etc/ssl/ca-bundle.crt") + os.Setenv("SECRET_KEY", "should-not-inherit") + + // Create server config with tier1+tier2 mode + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1Tier2, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify tier1 vars are present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited (tier1 var)") + } + if _, ok := resultMap["PATH"]; !ok { + t.Error("PATH should be inherited (tier1 var)") + } + + // Verify tier2 vars are present + if _, ok := resultMap["SSL_CERT_FILE"]; !ok { + t.Error("SSL_CERT_FILE should be inherited (tier2 var)") + } + if _, ok := resultMap["CURL_CA_BUNDLE"]; !ok { + t.Error("CURL_CA_BUNDLE should be inherited (tier2 var)") + } + + // Verify non-tier vars are NOT present + if _, ok := resultMap["SECRET_KEY"]; ok { + t.Error("SECRET_KEY should NOT be inherited (not in any tier)") + } +} + +// TestBuildEnvironment_ModeAll tests that mode=all includes Tier1+Tier2 and can use Extra for additional vars +func TestBuildEnvironment_ModeAll(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("SSL_CERT_FILE", "/etc/ssl/cert.pem") + os.Setenv("CUSTOM_VAR", "custom-value") + os.Setenv("SECRET_KEY", "secret123") + + // Create server config with mode=all + extra vars + // Note: mode=all means Tier1+Tier2, not "inherit everything" + // To inherit additional vars, use Extra list + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritAll, + Extra: []string{"CUSTOM_VAR", "SECRET_KEY"}, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify Tier1 vars are present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited (tier1 var)") + } + if _, ok := resultMap["PATH"]; !ok { + t.Error("PATH should be inherited (tier1 var)") + } + + // Verify Tier2 vars are present + if _, ok := resultMap["SSL_CERT_FILE"]; !ok { + t.Error("SSL_CERT_FILE should be inherited (tier2 var)") + } + + // Verify extra vars are present + if resultMap["CUSTOM_VAR"] != "custom-value" { + t.Error("CUSTOM_VAR should be inherited (extra var)") + } + if resultMap["SECRET_KEY"] != "secret123" { + t.Error("SECRET_KEY should be inherited (extra var)") + } +} + +// TestBuildEnvironment_ExtraVariables tests that explicitly requested vars are added +func TestBuildEnvironment_ExtraVariables(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("PYTHONPATH", "/opt/python/lib") + os.Setenv("VIRTUAL_ENV", "/opt/venv") + + // Create server config with tier1 + extras + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + Extra: []string{"PYTHONPATH", "VIRTUAL_ENV"}, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify tier1 vars are present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited (tier1 var)") + } + + // Verify extra vars are present + if resultMap["PYTHONPATH"] != "/opt/python/lib" { + t.Error("PYTHONPATH should be inherited (extra var)") + } + if resultMap["VIRTUAL_ENV"] != "/opt/venv" { + t.Error("VIRTUAL_ENV should be inherited (extra var)") + } +} + +// TestBuildEnvironment_PrefixMatching tests that variables matching prefixes are added +func TestBuildEnvironment_PrefixMatching(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("DATTO_API_KEY", "key123") + os.Setenv("DATTO_API_URL", "https://api.datto.com") + os.Setenv("DATTO_DEBUG", "true") + os.Setenv("OTHER_VAR", "should-not-match") + + // Create server config with tier1 + DATTO_ prefix + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + Prefix: []string{"DATTO_"}, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify tier1 vars are present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited (tier1 var)") + } + + // Verify all DATTO_ prefixed vars are present + if resultMap["DATTO_API_KEY"] != "key123" { + t.Error("DATTO_API_KEY should be inherited (prefix match)") + } + if resultMap["DATTO_API_URL"] != "https://api.datto.com" { + t.Error("DATTO_API_URL should be inherited (prefix match)") + } + if resultMap["DATTO_DEBUG"] != "true" { + t.Error("DATTO_DEBUG should be inherited (prefix match)") + } + + // Verify non-matching vars are NOT present + if _, ok := resultMap["OTHER_VAR"]; ok { + t.Error("OTHER_VAR should NOT be inherited (no prefix match)") + } +} + +// TestBuildEnvironment_Denylist tests that denied vars are blocked +func TestBuildEnvironment_Denylist(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("SSH_AUTH_SOCK", "/tmp/ssh-agent") + + // Create server config with mode=all + deny SSH_AUTH_SOCK + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritAll, + Deny: []string{"SSH_AUTH_SOCK"}, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify allowed vars are present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited") + } + if _, ok := resultMap["PATH"]; !ok { + t.Error("PATH should be inherited") + } + + // Verify denied var is NOT present + if _, ok := resultMap["SSH_AUTH_SOCK"]; ok { + t.Error("SSH_AUTH_SOCK should be denied") + } +} + +// TestBuildEnvironment_AllowDeniedIfExplicit tests that explicitly requested vars override denylist +func TestBuildEnvironment_AllowDeniedIfExplicit(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("SSH_AUTH_SOCK", "/tmp/ssh-agent") + os.Setenv("HTTP_PROXY", "http://proxy:8080") + + // Create server config with deny + allow_denied_if_explicit + extra + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + Extra: []string{"SSH_AUTH_SOCK", "HTTP_PROXY"}, + Deny: []string{"SSH_AUTH_SOCK"}, + AllowDeniedIfExplicit: true, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify denied but explicitly requested var IS present (because allow_denied_if_explicit=true) + if resultMap["SSH_AUTH_SOCK"] != "/tmp/ssh-agent" { + t.Error("SSH_AUTH_SOCK should be allowed (explicitly requested + allow_denied_if_explicit)") + } + + // Verify HTTP_PROXY is also allowed (in implicit denylist + Extra list + allow_denied_if_explicit) + if resultMap["HTTP_PROXY"] != "http://proxy:8080" { + t.Error("HTTP_PROXY should be allowed (implicit denylist but in Extra + allow_denied_if_explicit)") + } +} + +// TestBuildEnvironment_HTTPProxyBlocked tests that uppercase HTTP_PROXY is blocked by default +func TestBuildEnvironment_HTTPProxyBlocked(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("HTTP_PROXY", "http://proxy:8080") + os.Setenv("HTTPS_PROXY", "https://proxy:8080") + os.Setenv("http_proxy", "http://proxy:8080") + os.Setenv("https_proxy", "https://proxy:8080") + + // Create server config with mode=all (should still block HTTP_PROXY variants) + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritAll, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify HTTP_PROXY variants are blocked + proxyVars := []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} + for _, varName := range proxyVars { + if _, ok := resultMap[varName]; ok { + t.Errorf("%s should be blocked (implicit denylist)", varName) + } + } + + // Verify other vars still work + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited") + } +} + +// TestBuildEnvironment_OverridePrecedence tests that explicit overrides win over inherited +func TestBuildEnvironment_OverridePrecedence(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("PATH", "/usr/bin") + os.Setenv("CUSTOM", "parent-value") + + // Create server config with tier1 mode and explicit overrides + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + }, + Env: map[string]string{ + "PATH": "/custom/bin", + "CUSTOM": "override-value", + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify overrides win + if resultMap["PATH"] != "/custom/bin" { + t.Errorf("PATH override should win, got %q", resultMap["PATH"]) + } + if resultMap["CUSTOM"] != "override-value" { + t.Errorf("CUSTOM override should win, got %q", resultMap["CUSTOM"]) + } + + // Verify overrides bypass denylist + os.Setenv("HTTP_PROXY", "http://parent:8080") + serverCfg.Env["HTTP_PROXY"] = "http://override:8080" + + result = BuildEnvironment(serverCfg, nil) + resultMap = sliceToMap(result) + + if resultMap["HTTP_PROXY"] != "http://override:8080" { + t.Errorf("HTTP_PROXY override should bypass denylist, got %q", resultMap["HTTP_PROXY"]) + } +} + +// TestBuildEnvironment_Windows tests Windows case-insensitive behavior +func TestBuildEnvironment_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Skipping Windows-specific test") + } + + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment + os.Clearenv() + os.Setenv("PATH", "C:\\Windows\\System32") + os.Setenv("HOME", "C:\\Users\\test") + + // Create server config with case-variant override + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + }, + Env: map[string]string{ + "Path": "C:\\Custom\\Path", // Different case + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // On Windows, PATH and Path should be treated as the same variable + pathValue := "" + pathKey := "" + for key, val := range resultMap { + if strings.ToUpper(key) == "PATH" { + pathValue = val + pathKey = key + break + } + } + + if pathValue != "C:\\Custom\\Path" { + t.Errorf("Expected PATH to be overridden (case-insensitive), got key=%q value=%q", pathKey, pathValue) + } + + // Verify only one PATH variant exists + pathCount := 0 + for key := range resultMap { + if strings.ToUpper(key) == "PATH" { + pathCount++ + } + } + if pathCount != 1 { + t.Errorf("Expected exactly 1 PATH variant, got %d: %v", pathCount, resultMap) + } + + // HOME should still be present + if _, ok := resultMap["HOME"]; !ok { + t.Error("HOME should be inherited") + } +} + +// TestBuildEnvironment_LocaleVariables tests that LC_* locale vars can be inherited via prefix +func TestBuildEnvironment_LocaleVariables(t *testing.T) { + // Save and restore environment + oldEnv := os.Environ() + defer restoreEnvironment(oldEnv) + + // Set up test environment with various LC_* vars + os.Clearenv() + os.Setenv("HOME", "/home/user") + os.Setenv("LANG", "en_US.UTF-8") + os.Setenv("LC_ALL", "en_US.UTF-8") + os.Setenv("LC_TIME", "en_GB.UTF-8") + os.Setenv("LC_NUMERIC", "de_DE.UTF-8") + os.Setenv("LC_MONETARY", "fr_FR.UTF-8") + os.Setenv("OTHER_VAR", "should-not-match") + + // Create server config with tier1 mode + LC_ prefix to capture all locale vars + serverCfg := &config.ServerConfig{ + Inherit: &config.InheritConfig{ + Mode: config.InheritTier1, + Prefix: []string{"LC_"}, + }, + } + + // Build environment + result := BuildEnvironment(serverCfg, nil) + resultMap := sliceToMap(result) + + // Verify base locale vars are present (LANG and LC_ALL are in Tier1) + if resultMap["LANG"] != "en_US.UTF-8" { + t.Error("LANG should be inherited (tier1 var)") + } + if resultMap["LC_ALL"] != "en_US.UTF-8" { + t.Error("LC_ALL should be inherited (tier1 var)") + } + + // Verify all LC_* vars are present (via prefix matching) + localeVars := []string{"LC_TIME", "LC_NUMERIC", "LC_MONETARY"} + for _, varName := range localeVars { + if _, ok := resultMap[varName]; !ok { + t.Errorf("%s should be inherited (LC_* prefix match)", varName) + } + } + + // Verify non-LC_* var is NOT present + if _, ok := resultMap["OTHER_VAR"]; ok { + t.Error("OTHER_VAR should NOT be inherited") + } +} + +// restoreEnvironment restores the environment to a previous state +func restoreEnvironment(oldEnv []string) { + os.Clearenv() + for _, entry := range oldEnv { + key, value := splitEnvEntry(entry) + if key != "" { + os.Setenv(key, value) + } + } +} diff --git a/client/stdio_client.go b/client/stdio_client.go index fd40dd2..60a0b25 100644 --- a/client/stdio_client.go +++ b/client/stdio_client.go @@ -9,6 +9,8 @@ import ( "os/exec" "sync" "time" + + "mcp-debug/config" ) // StdioClient implements MCPClient using stdio transport @@ -17,13 +19,14 @@ type StdioClient struct { command string args []string env []string - + inheritCfg *config.InheritConfig // NEW: inheritance configuration + cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser reader *bufio.Reader idGen *RequestIDGenerator - + connected bool mu sync.Mutex } @@ -43,6 +46,11 @@ func (c *StdioClient) SetEnvironment(env []string) { c.env = env } +// SetInheritConfig sets the inheritance configuration for environment variables +func (c *StdioClient) SetInheritConfig(cfg *config.InheritConfig) { + c.inheritCfg = cfg +} + // Connect establishes connection to the MCP server func (c *StdioClient) Connect(ctx context.Context) error { c.mu.Lock() @@ -54,9 +62,28 @@ func (c *StdioClient) Connect(ctx context.Context) error { // Create command c.cmd = exec.CommandContext(ctx, c.command, c.args...) - if c.env != nil { - c.cmd.Env = c.env + if c.env != nil || c.inheritCfg != nil { + // Convert []string env to map[string]string for overrides + overrides := make(map[string]string) + if c.env != nil { + for _, entry := range c.env { + key, value := splitEnvEntry(entry) + if key != "" { + overrides[key] = value + } + } + } + + // Build a minimal ServerConfig with environment overrides and inheritance config + serverConfig := &config.ServerConfig{ + Env: overrides, + Inherit: c.inheritCfg, + } + + // BuildEnvironment handles defaulting to tier1 if Inherit is nil + c.cmd.Env = BuildEnvironment(serverConfig, nil) } + // Note: When both c.env and c.inheritCfg are nil, c.cmd.Env stays nil (Go's default) // Create pipes stdin, err := c.cmd.StdinPipe() diff --git a/config/types.go b/config/types.go index 815b7a4..590811d 100644 --- a/config/types.go +++ b/config/types.go @@ -7,23 +7,44 @@ import ( "time" ) +// InheritMode defines how environment variables are inherited +type InheritMode string + +const ( + InheritNone InheritMode = "none" + InheritTier1 InheritMode = "tier1" + InheritTier1Tier2 InheritMode = "tier1+tier2" + InheritAll InheritMode = "all" +) + +// InheritConfig controls which environment variables are inherited +type InheritConfig struct { + Mode InheritMode `yaml:"mode,omitempty"` + Extra []string `yaml:"extra,omitempty"` + Prefix []string `yaml:"prefix,omitempty"` + Deny []string `yaml:"deny,omitempty"` + AllowDeniedIfExplicit bool `yaml:"allow_denied_if_explicit,omitempty"` +} + // ProxyConfig represents the main configuration for the proxy server type ProxyConfig struct { Servers []ServerConfig `yaml:"servers"` Proxy ProxySettings `yaml:"proxy"` + Inherit *InheritConfig `yaml:"inherit,omitempty"` // NEW: proxy-level defaults } // ServerConfig represents configuration for a remote MCP server type ServerConfig struct { - Name string `yaml:"name"` - Prefix string `yaml:"prefix"` - Transport string `yaml:"transport"` - Command string `yaml:"command,omitempty"` - Args []string `yaml:"args,omitempty"` + Name string `yaml:"name"` + Prefix string `yaml:"prefix"` + Transport string `yaml:"transport"` + Command string `yaml:"command,omitempty"` + Args []string `yaml:"args,omitempty"` Env map[string]string `yaml:"env,omitempty"` - URL string `yaml:"url,omitempty"` - Auth *AuthConfig `yaml:"auth,omitempty"` - Timeout string `yaml:"timeout,omitempty"` + Inherit *InheritConfig `yaml:"inherit,omitempty"` // NEW: per-server inheritance + URL string `yaml:"url,omitempty"` + Auth *AuthConfig `yaml:"auth,omitempty"` + Timeout string `yaml:"timeout,omitempty"` } // AuthConfig represents authentication configuration @@ -93,8 +114,15 @@ func (c *ProxyConfig) Validate() error { return fmt.Errorf("server %s: invalid timeout format: %w", server.Name, err) } } + + // Validate server-level inherit config + if server.Inherit != nil { + if err := server.Inherit.Validate(); err != nil { + return fmt.Errorf("server %s: inherit: %w", server.Name, err) + } + } } - + // Validate proxy settings if c.Proxy.HealthCheckInterval != "" { if _, err := time.ParseDuration(c.Proxy.HealthCheckInterval); err != nil { @@ -107,37 +135,69 @@ func (c *ProxyConfig) Validate() error { return fmt.Errorf("invalid connectionTimeout format: %w", err) } } - + + // Validate proxy-level inherit config + if c.Inherit != nil { + if err := c.Inherit.Validate(); err != nil { + return fmt.Errorf("proxy.inherit: %w", err) + } + } + return nil } // ExpandEnvVars expands environment variables in configuration values func (c *ProxyConfig) ExpandEnvVars() { + // Expand proxy-level inheritance config + expandInheritConfig(c.Inherit) + for i := range c.Servers { server := &c.Servers[i] - + // Expand command server.Command = expandEnvVar(server.Command) - + // Expand args for j := range server.Args { server.Args[j] = expandEnvVar(server.Args[j]) } - + // Expand environment variables for key, value := range server.Env { server.Env[key] = expandEnvVar(value) } - + // Expand URL server.URL = expandEnvVar(server.URL) - + // Expand auth fields if server.Auth != nil { server.Auth.Token = expandEnvVar(server.Auth.Token) server.Auth.Username = expandEnvVar(server.Auth.Username) server.Auth.Password = expandEnvVar(server.Auth.Password) } + + // Expand server-level inheritance config + expandInheritConfig(server.Inherit) + } +} + +// expandInheritConfig expands environment variables in InheritConfig fields +func expandInheritConfig(ic *InheritConfig) { + if ic == nil { + return + } + + for i := range ic.Extra { + ic.Extra[i] = expandEnvVar(ic.Extra[i]) + } + + for i := range ic.Prefix { + ic.Prefix[i] = expandEnvVar(ic.Prefix[i]) + } + + for i := range ic.Deny { + ic.Deny[i] = expandEnvVar(ic.Deny[i]) } } @@ -172,7 +232,7 @@ func (s *ServerConfig) GetServerTimeout() time.Duration { // GetProxySettings returns proxy settings with defaults func (c *ProxyConfig) GetProxySettings() ProxySettings { settings := c.Proxy - + // Apply defaults if settings.HealthCheckInterval == "" { settings.HealthCheckInterval = "30s" @@ -183,6 +243,36 @@ func (c *ProxyConfig) GetProxySettings() ProxySettings { if settings.MaxRetries == 0 { settings.MaxRetries = 3 } - + return settings +} + +// ResolveInheritConfig returns the effective inheritance config for a server. +// Server-level config overrides proxy-level defaults. +func (s *ServerConfig) ResolveInheritConfig(proxyDefault *InheritConfig) *InheritConfig { + if s.Inherit != nil { + return s.Inherit + } + if proxyDefault != nil { + return proxyDefault + } + // Hardcoded default: tier1 mode + return &InheritConfig{ + Mode: InheritTier1, + } +} + +// Validate checks that the inheritance configuration is valid +func (ic *InheritConfig) Validate() error { + // Validate mode + switch ic.Mode { + case "", InheritNone, InheritTier1, InheritTier1Tier2, InheritAll: + // Valid modes (empty defaults to tier1) + default: + return fmt.Errorf("invalid mode %q: must be one of: none, tier1, tier1+tier2, all", ic.Mode) + } + + // Note: mode=none with extras/prefix is valid (inherit nothing except explicitly requested vars) + + return nil } \ No newline at end of file diff --git a/discovery/discoverer.go b/discovery/discoverer.go index 3a56610..bfcc6f1 100644 --- a/discovery/discoverer.go +++ b/discovery/discoverer.go @@ -126,7 +126,11 @@ func (d *Discoverer) discoverServer(ctx context.Context, serverConfig config.Ser // createStdioClient creates a stdio-based MCP client func (d *Discoverer) createStdioClient(serverConfig config.ServerConfig) (client.MCPClient, error) { stdioClient := client.NewStdioClient(serverConfig.Name, serverConfig.Command, serverConfig.Args) - + + // Set inheritance config + inheritCfg := serverConfig.ResolveInheritConfig(d.config.Inherit) + stdioClient.SetInheritConfig(inheritCfg) + // Set environment variables if specified if len(serverConfig.Env) > 0 { var env []string diff --git a/docs/RECORDING.md b/docs/RECORDING.md new file mode 100644 index 0000000..8129ef7 --- /dev/null +++ b/docs/RECORDING.md @@ -0,0 +1,397 @@ +# Session Recording & Playback + +MCP Debug provides comprehensive recording of JSON-RPC traffic in proxy mode, enabling debugging, documentation, and regression testing workflows. + +## Overview + +When running in proxy mode with the `--record` flag, mcp-debug captures: +- All tool call requests and responses +- Management tool operations (server_add, server_remove, etc.) +- Tool calls to all servers (both static and dynamically added) +- Error responses and connection failures +- Complete JSON-RPC message payloads + +## Quick Start + +```bash +# Start proxy with recording enabled +mcp-debug --proxy --config config.yaml --record session.jsonl + +# Use the proxy normally - all traffic is recorded +# Stop the proxy when done (Ctrl+C) + +# Examine the recording +cat session.jsonl +``` + +## Recording Format + +Recordings use **JSONL** (JSON Lines) format - one JSON object per line: + +```jsonl +# MCP Recording Session +# Started: 2026-01-12T23:44:33-07:00 +{"start_time":"2026-01-12T23:44:33.862903809-07:00","server_info":"Dynamic MCP Proxy v1.0.0","messages":[]} +{"timestamp":"2026-01-12T23:45:42.940680618-07:00","direction":"request","message_type":"tool_call","tool_name":"fs_read_file","server_name":"filesystem","message":{...}} +{"timestamp":"2026-01-12T23:45:43.123456789-07:00","direction":"response","message_type":"tool_call","tool_name":"fs_read_file","server_name":"filesystem","message":{...}} +``` + +### File Structure + +1. **Comment Lines** (lines 1-2): Human-readable session metadata +2. **Session Header** (line 3): JSON object with session information +3. **Message Lines** (line 4+): One JSON object per message + +### Session Header + +```json +{ + "start_time": "2026-01-12T23:44:33.862903809-07:00", + "server_info": "Dynamic MCP Proxy v1.0.0", + "messages": [] +} +``` + +Fields: +- `start_time`: ISO 8601 timestamp when recording started +- `server_info`: Proxy version information +- `messages`: Always empty array (messages stored as separate lines) + +### Message Format + +Each recorded message is a JSON object with these fields: + +```json +{ + "timestamp": "2026-01-12T23:45:42.940680618-07:00", + "direction": "request", + "message_type": "tool_call", + "tool_name": "fs_read_file", + "server_name": "filesystem", + "message": { + "method": "tools/call", + "params": { + "name": "fs_read_file", + "arguments": { + "path": "/etc/hosts" + } + } + } +} +``` + +Fields: +- `timestamp`: ISO 8601 timestamp when message was captured +- `direction`: Either `"request"` or `"response"` +- `message_type`: Type of message (currently always `"tool_call"`) +- `tool_name`: Prefixed tool name (e.g., `fs_read_file`, `math_calculate`) +- `server_name`: Name of the upstream MCP server +- `message`: Complete JSON-RPC message payload + +## What Gets Recorded + +### Static Servers (from config.yaml) + +All tool calls to servers defined in your configuration are recorded: + +```yaml +servers: + - name: "filesystem" + prefix: "fs" + command: "npx -y @mcp/filesystem /path" +``` + +Tool calls like `fs_read_file`, `fs_write_file`, etc. are captured. + +### Dynamic Servers (via server_add) + +Servers added at runtime are also recorded: + +```json +// server_add request is recorded +{ + "direction": "request", + "tool_name": "server_add", + "server_name": "proxy", + "message": { + "name": "database", + "command": "python3 db_server.py" + } +} + +// Subsequent tool calls to the new server are recorded +{ + "direction": "request", + "tool_name": "database_query", + "server_name": "database", + ... +} +``` + +### Management Tools + +All proxy management operations are recorded: +- `server_add` - Adding new servers +- `server_remove` - Removing servers +- `server_disconnect` - Disconnecting servers +- `server_reconnect` - Reconnecting with new commands +- `server_list` - Listing server status + +### Error Responses + +Failed requests and error responses are recorded: + +```json +{ + "direction": "response", + "tool_name": "fs_read_file", + "message": { + "content": [{ + "type": "text", + "text": "Error: File not found" + }], + "isError": true + } +} +``` + +## Playback Modes + +### Client Mode + +Replay recorded requests to test a server: + +```bash +# Replay requests from recording to a server +mcp-debug --playback-client session.jsonl | ./your-mcp-server + +# The server receives the recorded requests and can respond +``` + +**Use Cases**: +- Testing server implementations against real traffic +- Regression testing (compare responses to recorded ones) +- Debugging server behavior with specific requests + +### Server Mode + +Replay recorded responses to test a client: + +```bash +# Start playback server (use with mcp-tui or other clients) +mcp-tui mcp-debug --playback-server session.jsonl +``` + +**Use Cases**: +- Testing client behavior with known responses +- Simulating server responses without running real servers +- UI/integration testing + +## Common Workflows + +### Debugging Tool Calls + +1. Record a session where you encounter an issue: + ```bash + mcp-debug --proxy --config config.yaml --record debug-session.jsonl + ``` + +2. Examine the recording to see exact requests/responses: + ```bash + # Pretty-print messages + grep '"direction":"request"' debug-session.jsonl | jq . + + # See all tool names used + grep -o '"tool_name":"[^"]*"' debug-session.jsonl | sort | uniq + + # Extract specific tool's messages + grep '"tool_name":"fs_read_file"' debug-session.jsonl | jq . + ``` + +3. Identify the problematic request and inspect the payload + +### Regression Testing + +1. Record a "golden" session with correct behavior: + ```bash + mcp-debug --proxy --config config.yaml --record golden.jsonl + ``` + +2. After making changes, record a new session: + ```bash + mcp-debug --proxy --config config.yaml --record test.jsonl + ``` + +3. Compare recordings: + ```bash + # Extract and compare responses + grep '"direction":"response"' golden.jsonl > golden-responses.jsonl + grep '"direction":"response"' test.jsonl > test-responses.jsonl + diff golden-responses.jsonl test-responses.jsonl + ``` + +### Documentation & Examples + +1. Record typical usage patterns: + ```bash + mcp-debug --proxy --config config.yaml --record examples.jsonl + ``` + +2. Extract example requests for documentation: + ```bash + # Get all unique tool calls + jq -r 'select(.direction=="request") | .tool_name' examples.jsonl | sort | uniq + + # Extract example for specific tool + jq 'select(.tool_name=="fs_read_file" and .direction=="request") | .message.params.arguments' examples.jsonl | head -1 + ``` + +## Tips & Best Practices + +### File Naming + +Use descriptive names for recordings: +```bash +--record 2026-01-12-filesystem-operations.jsonl +--record user-authentication-flow.jsonl +--record error-case-missing-file.jsonl +``` + +### Filtering Messages + +Use `jq` to filter and analyze recordings: + +```bash +# Count messages by direction +jq -s 'group_by(.direction) | map({direction: .[0].direction, count: length})' session.jsonl + +# List all servers used +jq -r '.server_name' session.jsonl | sort | uniq + +# Find slow operations (>1 second between request/response) +# (requires custom scripting to correlate timestamps) + +# Extract error responses +jq 'select(.message.isError == true)' session.jsonl +``` + +### Recording Size + +Recordings can grow large with many messages or large payloads: + +```bash +# Check recording size +ls -lh session.jsonl + +# Count messages +grep -c '"direction":' session.jsonl + +# Compress old recordings +gzip session-2026-01-01.jsonl +``` + +### Sensitive Data + +⚠️ **Warning**: Recordings contain complete message payloads including: +- File paths and contents +- API keys or credentials passed as arguments +- Database query results +- Any data transmitted through the proxy + +**Best Practices**: +- Don't commit recordings to version control unless sanitized +- Use `.gitignore` to exclude `*.jsonl` files +- Redact sensitive data before sharing recordings +- Store recordings securely + +### Continuous Recording + +Enable automatic recording with environment variable: + +```bash +export MCP_RECORD_FILE="session.jsonl" +mcp-debug --proxy --config config.yaml +``` + +This records all sessions to the specified file (overwrites on each run). + +## Limitations + +### Current Limitations + +- **MCP Protocol Messages**: Only tool calls are recorded. MCP protocol messages (initialize, tools/list, ping) are not yet captured. +- **Multiple Formats**: Playback client/server modes have separate implementations and may not support all recorded message types. +- **No Filtering**: All messages are recorded; cannot exclude specific tools or servers. +- **Overwrite Mode**: Recording always overwrites the target file; no append mode. + +### Future Enhancements + +Planned improvements: +- Record full MCP protocol layer (initialize, notifications, etc.) +- Selective recording (filter by server, tool, or pattern) +- Append mode for long-running sessions +- Recording analysis tools (stats, timeline, visualization) +- Built-in diff/comparison tools + +## Troubleshooting + +### No Messages Recorded + +If recording file is created but contains no messages: + +1. **Verify recording is enabled**: + ```bash + # Should see "Recording enabled to: session.jsonl" in logs + mcp-debug --proxy --config config.yaml --record session.jsonl --log /dev/stdout + ``` + +2. **Check if tools are registered**: + ```bash + # Should see "Registered tool: server_tool_name" for each tool + ``` + +3. **Verify tool calls are being made**: + - Connect with mcp-tui and try calling tools + - Check proxy logs for tool call activity + +### Invalid JSON in Recording + +If parsing fails: + +```bash +# Find invalid lines +jq . session.jsonl 2>&1 | grep -A2 "parse error" + +# Validate each line +awk 'NR > 3' session.jsonl | while read line; do echo "$line" | jq . >/dev/null || echo "Line $NR invalid"; done +``` + +### Playback Failures + +If playback doesn't work as expected: + +1. **Verify recording format**: + ```bash + # Header should be single-line JSON + sed -n '3p' session.jsonl | jq . + + # Messages should have required fields + sed -n '4p' session.jsonl | jq '{direction, tool_name, message}' + ``` + +2. **Check server compatibility**: + - Ensure server expects JSON-RPC format + - Verify tool names match server's registered tools + +## Related Documentation + +- [README.md](../README.md) - Main project documentation +- [Configuration Guide](../README.md#configuration) - Server configuration +- [Environment Variables](../README.md#environment-variables) - Recording environment settings + +## Support + +For issues with recording: +1. Check the [GitHub Issues](https://github.com/ExactDoug/mcp-debug/issues) +2. Enable debug logging: `MCP_DEBUG=1 mcp-debug ...` +3. Include recording file samples (with sensitive data redacted) when reporting issues diff --git a/examples/config-inheritance-advanced.yaml b/examples/config-inheritance-advanced.yaml new file mode 100644 index 0000000..be76fe0 --- /dev/null +++ b/examples/config-inheritance-advanced.yaml @@ -0,0 +1,178 @@ +# Advanced Environment Inheritance Example +# DRAFT - Not yet tested with real-world MCP servers +# +# This example demonstrates advanced features including: +# - mode: all (inherit everything with deny lists) +# - allow_denied_if_explicit (override implicit denylist) +# - Proxy-level defaults with per-server overrides +# - HTTP_PROXY override (httpoxy mitigation bypass) +# +# See ../DRAFT_ENV_INHERITANCE.md for complete documentation. + +# Proxy-level defaults +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 + + # Default inheritance for all servers + inherit: + mode: tier1 # Conservative default + deny: + # Block these variables for ALL servers by default + - SSH_AUTH_SOCK + - AWS_SESSION_TOKEN + # Servers can override this entire config + +servers: + # Example 1: Trusted in-house server with mode: all + - name: trusted-internal-server + transport: stdio + command: ./internal-server + inherit: + mode: all # Inherit everything from parent + deny: + # Even in mode:all, explicitly block these secrets + - AWS_SECRET_ACCESS_KEY + - AZURE_CLIENT_SECRET + - GITHUB_TOKEN + - ANTHROPIC_API_KEY + - OPENAI_API_KEY + # + # Inherited variables: + # - Everything in parent environment + # - EXCEPT the five denied credentials above + # - EXCEPT proxy-level denies (SSH_AUTH_SOCK, AWS_SESSION_TOKEN) + # - EXCEPT implicit denylist (HTTP_PROXY, https_proxy, etc.) + # + # Use case: Fully trusted internal server that needs broad environment + # access but should never see production API keys + + # Example 2: Corporate proxy server with httpoxy override + - name: corporate-proxy-client + transport: stdio + command: python3 + args: ["-m", "proxy_aware_server"] + inherit: + mode: tier1+tier2 + extra: + # Request lowercase proxy variables (safer than uppercase) + - http_proxy + - https_proxy + - no_proxy + allow_denied_if_explicit: true # Allow proxy vars from implicit denylist + # + # Inherited variables: + # - All tier1 + tier2 variables + # - http_proxy, https_proxy, no_proxy (overriding implicit denylist) + # + # Security note: We explicitly request lowercase variants (safer) and + # set allow_denied_if_explicit to bypass the implicit denylist. + # Only do this if you trust the server and need proxy support. + # + # The uppercase variants (HTTP_PROXY, HTTPS_PROXY) remain blocked + # unless explicitly added to 'extra' list. + # + # Use case: Corporate environment requiring HTTP proxy for outbound + # connections, with trusted MCP server + + # Example 3: Multi-tenant server with complete isolation + - name: tenant-a-server + transport: stdio + command: python3 + args: ["-m", "multitenant_server"] + inherit: + mode: none # No inheritance at all + env: + # Only these explicitly defined variables are set + TENANT_ID: "tenant-a" + DB_HOST: "tenant-a-db.internal" + DB_NAME: "tenant_a_production" + DB_USER: "tenant_a_user" + DB_PASSWORD: "${TENANT_A_DB_PASSWORD}" # Expanded from parent + ISOLATION_LEVEL: "maximum" + # + # Inherited variables: + # - NONE from parent environment + # - Only the env: block values above + # + # Use case: Multi-tenant architecture requiring complete environment + # isolation between tenants + + # Example 4: Development server with relaxed inheritance + - name: dev-server + transport: stdio + command: python3 + args: ["-m", "dev_server"] + inherit: + mode: all # Inherit everything (convenient for development) + deny: [] # Don't deny anything (override proxy defaults) + allow_denied_if_explicit: true + env: + ENVIRONMENT: "development" + DEBUG: "true" + # + # Inherited variables: + # - Everything in parent environment + # - Including SSH_AUTH_SOCK (proxy deny overridden) + # - Including proxy variables (implicit denylist overridden) + # + # Use case: Local development where you want maximum convenience + # and aren't worried about secret leakage + # + # WARNING: Never use this configuration for untrusted/experimental + # servers or in production environments + + # Example 5: Minimal server using proxy defaults + - name: default-behavior-server + transport: stdio + command: python3 + args: ["-m", "basic_server"] + # No inherit block = uses proxy.inherit defaults + # + # Inherited variables: + # - Tier1 (from proxy default mode: tier1) + # - NOT SSH_AUTH_SOCK (from proxy deny list) + # - NOT AWS_SESSION_TOKEN (from proxy deny list) + # + # Use case: Standard server using secure defaults + + # Example 6: Override proxy defaults for specific server + - name: override-proxy-defaults + transport: stdio + command: node + args: ["special-server.js"] + inherit: + mode: tier1+tier2 # Override proxy's tier1 + extra: + - NODE_ENV + - SSH_AUTH_SOCK # Include despite proxy deny + deny: + - GITHUB_TOKEN # Add additional denial + allow_denied_if_explicit: true # Allow SSH_AUTH_SOCK from extra + # + # Inherited variables: + # - Tier1 + tier2 (overriding proxy default) + # - SSH_AUTH_SOCK (explicitly allowed despite proxy deny) + # - NODE_ENV + # - NOT GITHUB_TOKEN (explicitly denied) + # - NOT AWS_SESSION_TOKEN (still from proxy deny) + # + # Use case: Server needs SSH for git operations but should never + # see GitHub token (use SSH key instead) + + # Example 7: Testing/debugging server that prints environment + - name: env-inspector + transport: stdio + command: /usr/bin/env # Prints all environment variables + inherit: + mode: tier1 + extra: ["TEST_VAR1", "TEST_VAR2"] + # + # Use this server to verify what variables are actually inherited. + # The /usr/bin/env command prints all environment variables it receives. + # + # Run: uvx mcp-debug --proxy --config this-file.yaml + # Then use server_list or server tools to see output + # + # Use case: Debugging inheritance configuration diff --git a/examples/config-inheritance-basic.yaml b/examples/config-inheritance-basic.yaml new file mode 100644 index 0000000..393820a --- /dev/null +++ b/examples/config-inheritance-basic.yaml @@ -0,0 +1,65 @@ +# Basic Environment Inheritance Example +# DRAFT - Not yet tested with real-world MCP servers +# +# This example demonstrates the default tier1 mode, which inherits only +# baseline environment variables necessary for most programs to function. +# +# Security: This is the most secure default mode, preventing leakage of +# credentials, tokens, and other sensitive environment variables. +# +# See ../DRAFT_ENV_INHERITANCE.md for complete documentation. + +servers: + # Example 1: Default behavior (tier1 implicit) + - name: basic-python-server + transport: stdio + command: python3 + args: ["-m", "my_python_mcp_server"] + # No inherit block specified = tier1 mode (default) + # + # Inherited variables: + # - PATH, HOME, USER, SHELL (system basics) + # - LANG, LC_ALL, TZ (locale/timezone) + # - TMPDIR, TEMP, TMP (temporary directories) + # + # NOT inherited: + # - AWS_ACCESS_KEY_ID, GITHUB_TOKEN (credentials) + # - SSH_AUTH_SOCK (SSH agent) + # - Any custom application variables + + # Example 2: Explicit tier1 mode with minimal extras + - name: python-with-path + transport: stdio + command: python3 + args: ["-m", "another_server"] + inherit: + mode: tier1 # Explicit (same as default) + extra: ["PYTHONPATH"] # Add one additional variable + # + # Inherited variables: + # - All tier1 variables (PATH, HOME, USER, etc.) + # - PYTHONPATH (explicitly added) + # + # Use case: Python server needs custom module path + + # Example 3: Explicit environment overrides + - name: configured-server + transport: stdio + command: node + args: ["server.js"] + inherit: + mode: tier1 + env: + # These explicit overrides are ALWAYS set, regardless of inheritance mode + NODE_ENV: "production" + LOG_LEVEL: "info" + # Expand from parent environment + API_KEY: "${MY_API_KEY}" + # + # Note: env: overrides are never blocked by deny lists + # They always win over inherited variables + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 diff --git a/examples/config-inheritance-none.yaml b/examples/config-inheritance-none.yaml new file mode 100644 index 0000000..6bea6a8 --- /dev/null +++ b/examples/config-inheritance-none.yaml @@ -0,0 +1,171 @@ +# Minimal Environment Inheritance Example (mode: none) +# DRAFT - Not yet tested with real-world MCP servers +# +# This example demonstrates mode: none, which provides maximum isolation +# by inheriting no environment variables automatically. Only explicitly +# defined variables in the env: block are passed to the server. +# +# Security: This is the most secure mode, providing complete control over +# what environment the server sees. Use for untrusted servers or when you +# need guaranteed isolation. +# +# See ../DRAFT_ENV_INHERITANCE.md for complete documentation. + +servers: + # Example 1: Complete isolation with explicit configuration + - name: isolated-server + transport: stdio + command: python3 + args: ["-m", "untrusted_mcp_server"] + inherit: + mode: none # No automatic inheritance + env: + # Only these variables will be set in the server environment + PATH: "/usr/local/bin:/usr/bin:/bin" + HOME: "/tmp/isolated-home" + PYTHONPATH: "/opt/mcp-servers/lib" + LOG_LEVEL: "warning" + ENVIRONMENT: "isolated" + # + # Inherited variables: + # - ONLY the five variables defined in env: above + # - Nothing from parent environment + # + # Use case: Maximum security for untrusted or experimental servers + + # Example 2: Minimal with explicit extras (still isolated from most vars) + - name: semi-isolated-server + transport: stdio + command: node + args: ["server.js"] + inherit: + mode: none + extra: + # These specific variables will be inherited IF they exist + - PATH + - HOME + - NODE_ENV + env: + # Explicit overrides (always set) + SERVICE_NAME: "isolated-node-server" + PORT: "3000" + # + # Inherited variables: + # - PATH, HOME, NODE_ENV (if they exist in parent) + # - SERVICE_NAME, PORT (explicit overrides) + # - Nothing else from parent environment + # + # Note: This is similar to tier1, but you have explicit control + # over exactly which variables are allowed + + # Example 3: Multi-tenant isolation + - name: tenant-a + transport: stdio + command: python3 + args: ["-m", "tenant_server", "--tenant=a"] + inherit: + mode: none + env: + TENANT_ID: "a" + DB_URL: "postgresql://tenant-a-db/production" + STORAGE_PATH: "/data/tenant-a" + API_KEY: "${TENANT_A_API_KEY}" # Expanded from parent + MAX_CONNECTIONS: "10" + # + # Inherited variables: + # - Only the env: block above + # - TENANT_A_API_KEY is expanded from parent but not directly inherited + # + # Use case: Multi-tenant architecture where tenants must be completely + # isolated from each other's configuration + + - name: tenant-b + transport: stdio + command: python3 + args: ["-m", "tenant_server", "--tenant=b"] + inherit: + mode: none + env: + TENANT_ID: "b" + DB_URL: "postgresql://tenant-b-db/production" + STORAGE_PATH: "/data/tenant-b" + API_KEY: "${TENANT_B_API_KEY}" + MAX_CONNECTIONS: "10" + # + # Tenant B has identical structure but different configuration. + # Complete isolation ensures tenant A cannot see tenant B's credentials. + + # Example 4: Testing server with controlled environment + - name: test-server + transport: stdio + command: python3 + args: ["-m", "pytest", "-v"] + inherit: + mode: none + env: + # Minimal testing environment + PATH: "/usr/local/bin:/usr/bin:/bin" + HOME: "/tmp/test-home" + PYTHONPATH: "/workspace/tests" + PYTEST_CURRENT_TEST: "true" + TEST_ENVIRONMENT: "isolated" + # No access to production credentials or secrets + # + # Use case: Running tests in isolated environment to ensure they + # don't depend on local environment variables + + # Example 5: Containerized server simulation + - name: container-like-server + transport: stdio + command: python3 + args: ["-m", "containerized_server"] + inherit: + mode: none + extra: + # Minimal set similar to container defaults + - PATH + - HOME + env: + # Container-like environment + HOSTNAME: "mcp-server-container" + ENVIRONMENT: "production" + LOG_FORMAT: "json" + TZ: "UTC" + # + # Inherited variables: + # - PATH, HOME (if present) + # - HOSTNAME, ENVIRONMENT, LOG_FORMAT, TZ (explicit) + # + # Use case: Simulating a containerized environment where the server + # gets a minimal, controlled environment + + # Example 6: Mode none but allow denied if explicit + - name: isolated-with-escape-hatch + transport: stdio + command: python3 + args: ["-m", "special_server"] + inherit: + mode: none + extra: + - PATH + - HOME + - http_proxy # Normally in implicit denylist + - https_proxy + allow_denied_if_explicit: true # Allow proxy vars despite denylist + env: + CONFIG_FILE: "/etc/special-server/config.yaml" + # + # Inherited variables: + # - PATH, HOME (if present) + # - http_proxy, https_proxy (if present, despite denylist) + # - CONFIG_FILE (explicit) + # + # Use case: Isolated server that needs proxy access but nothing else + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 + +# Note: No proxy.inherit block specified. In mode: none, this doesn't +# matter since servers explicitly disable all inheritance anyway. diff --git a/examples/config-inheritance-tier2.yaml b/examples/config-inheritance-tier2.yaml new file mode 100644 index 0000000..f6ab881 --- /dev/null +++ b/examples/config-inheritance-tier2.yaml @@ -0,0 +1,103 @@ +# Tier1+Tier2 Environment Inheritance Example +# DRAFT - Not yet tested with real-world MCP servers +# +# This example demonstrates tier1+tier2 mode, which adds network and TLS +# variables to the baseline. Useful for servers making HTTPS requests, +# especially in enterprise environments with custom CA certificates. +# +# See ../DRAFT_ENV_INHERITANCE.md for complete documentation. + +servers: + # Example 1: API server with TLS support + - name: api-client-server + transport: stdio + command: python3 + args: ["-m", "api_mcp_server"] + inherit: + mode: tier1+tier2 # Baseline + network/TLS variables + extra: + - PYTHONPATH + - VIRTUAL_ENV + # + # Inherited variables: + # Tier 1: + # - PATH, HOME, USER, SHELL + # - LANG, LC_ALL, TZ + # - TMPDIR, TEMP, TMP + # Tier 2: + # - SSL_CERT_FILE, SSL_CERT_DIR (system TLS certs) + # - REQUESTS_CA_BUNDLE (Python requests library) + # - CURL_CA_BUNDLE (curl) + # - NODE_EXTRA_CA_CERTS (Node.js) + # Extra: + # - PYTHONPATH, VIRTUAL_ENV + # + # Use case: Python server making HTTPS API calls in corporate network + # with custom CA certificates for TLS inspection + + # Example 2: Application-specific variables with prefix matching + - name: datto-rmm-server + transport: stdio + command: python3 + args: ["-m", "datto_rmm_mcp.server"] + inherit: + mode: tier1+tier2 + prefix: + - DATTO_ # Inherit all DATTO_* variables + - RMM_ # Inherit all RMM_* variables + extra: + - PYTHONPATH + deny: + - SSH_AUTH_SOCK # Block SSH even if somehow matched + # + # Inherited variables: + # - All tier1 + tier2 variables + # - All variables starting with DATTO_ (e.g., DATTO_API_KEY, DATTO_URL) + # - All variables starting with RMM_ + # - PYTHONPATH + # - NOT SSH_AUTH_SOCK (explicitly denied) + # + # Use case: Application with multiple related environment variables + # that share a common prefix + + # Example 3: Node.js server in enterprise environment + - name: node-https-server + transport: stdio + command: node + args: ["enterprise-server.js"] + inherit: + mode: tier1+tier2 + extra: + - NODE_ENV + - NODE_OPTIONS + - NPM_CONFIG_REGISTRY # Corporate npm registry + deny: + - AWS_ACCESS_KEY_ID # Prevent cloud credential leakage + - AWS_SECRET_ACCESS_KEY + - GITHUB_TOKEN + env: + # Explicit configuration + SERVICE_NAME: "mcp-node-server" + LOG_LEVEL: "info" + # + # Inherited variables: + # - All tier1 + tier2 (including NODE_EXTRA_CA_CERTS) + # - NODE_ENV, NODE_OPTIONS, NPM_CONFIG_REGISTRY + # - NOT AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN + # - SERVICE_NAME, LOG_LEVEL (explicit overrides) + # + # Use case: Enterprise Node.js server with custom CA certificates, + # corporate npm registry, but blocking cloud credentials + +# Proxy-level defaults (applied to all servers unless overridden) +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 + + # Optional: Set default inheritance for all servers + # inherit: + # mode: tier1 + # extra: [] + # + # Individual servers can override these defaults diff --git a/integration/dynamic_proxy_server.go b/integration/dynamic_proxy_server.go index c434d50..6bcbef7 100644 --- a/integration/dynamic_proxy_server.go +++ b/integration/dynamic_proxy_server.go @@ -27,11 +27,12 @@ type DynamicProxyServer struct { clients map[string]client.MCPClient // server name -> client serverConfigs map[string]config.ServerConfig // server name -> config toolRegistry map[string][]string // server name -> list of tool names + config *config.ProxyConfig // Full proxy config including Inherit settings mu sync.RWMutex } // NewDynamicProxyServer creates a new dynamic proxy server -func NewDynamicProxyServer(cfg *config.ProxySettings) *DynamicProxyServer { +func NewDynamicProxyServer(cfg *config.ProxyConfig) *DynamicProxyServer { // Create MCP server with stdio transport mcpServer := mcp_golang.NewServer( stdio.NewStdioServerTransport(), @@ -45,6 +46,7 @@ func NewDynamicProxyServer(cfg *config.ProxySettings) *DynamicProxyServer { clients: make(map[string]client.MCPClient), serverConfigs: make(map[string]config.ServerConfig), toolRegistry: make(map[string][]string), + config: cfg, } } @@ -327,6 +329,11 @@ func (p *DynamicProxyServer) createAndConnectClient(ctx context.Context, serverC switch serverConfig.Transport { case "stdio": stdioClient := client.NewStdioClient(serverConfig.Name, serverConfig.Command, serverConfig.Args) + + // Set inheritance config + inheritCfg := serverConfig.ResolveInheritConfig(p.config.Inherit) + stdioClient.SetInheritConfig(inheritCfg) + if serverConfig.Env != nil { // Convert map[string]string to []string envSlice := make([]string, 0, len(serverConfig.Env)) diff --git a/integration/dynamic_wrapper.go b/integration/dynamic_wrapper.go index d205059..a312e54 100644 --- a/integration/dynamic_wrapper.go +++ b/integration/dynamic_wrapper.go @@ -86,7 +86,8 @@ func NewDynamicWrapper(cfg *config.ProxyConfig) *DynamicWrapper { func (w *DynamicWrapper) EnableRecording(filename string) error { w.recordMu.Lock() defer w.recordMu.Unlock() - + + if w.recordEnabled { return fmt.Errorf("recording already enabled") } @@ -106,10 +107,13 @@ func (w *DynamicWrapper) EnableRecording(filename string) error { Messages: []RecordedMessage{}, } - headerBytes, _ := json.MarshalIndent(session, "", " ") - fmt.Fprintf(file, "# MCP Recording Session\n# Started: %s\n%s\n", + headerBytes, _ := json.Marshal(session) + fmt.Fprintf(file, "# MCP Recording Session\n# Started: %s\n%s\n", session.StartTime.Format(time.RFC3339), string(headerBytes)) - + + // Inject recorder into proxy server for static server recording + w.proxyServer.recorderFunc = w.recordMessage + log.Printf("Recording enabled to: %s", filename) return nil } @@ -253,6 +257,11 @@ func (w *DynamicWrapper) handleServerAdd(ctx context.Context, request mcp.CallTo // Create and connect client stdioClient := client.NewStdioClient(name, serverConfig.Command, serverConfig.Args) + + // Use default inheritance (tier1 or proxy defaults) + inheritCfg := serverConfig.ResolveInheritConfig(w.proxyServer.config.Inherit) + stdioClient.SetInheritConfig(inheritCfg) + if err := stdioClient.Connect(ctx); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to connect: %v", err)), nil } @@ -495,6 +504,11 @@ func (w *DynamicWrapper) handleServerReconnect(ctx context.Context, request mcp. // Create and connect new client stdioClient := client.NewStdioClient(name, serverConfig.Command, serverConfig.Args) + + // Use default inheritance (tier1 or proxy defaults) + inheritCfg := serverConfig.ResolveInheritConfig(w.proxyServer.config.Inherit) + stdioClient.SetInheritConfig(inheritCfg) + if err := stdioClient.Connect(ctx); err != nil { // Mark as disconnected but keep tools registered serverInfo.IsConnected = false diff --git a/integration/proxy_server.go b/integration/proxy_server.go index 389fc32..383d621 100644 --- a/integration/proxy_server.go +++ b/integration/proxy_server.go @@ -22,7 +22,8 @@ type ProxyServer struct { registry *proxy.ToolRegistry clients []client.MCPClient discoverer *discovery.Discoverer - + recorderFunc proxy.RecorderFunc // Optional recorder for tool call traffic + mu sync.RWMutex initialized bool } @@ -47,13 +48,16 @@ func (p *ProxyServer) Initialize(ctx context.Context) error { } log.Println("Initializing Dynamic MCP Proxy Server...") - - // Create MCP server instance - p.mcpServer = server.NewMCPServer( - "Dynamic MCP Proxy", - "1.0.0", - server.WithToolCapabilities(true), - ) + + // Create MCP server instance ONLY if one doesn't exist + // (DynamicWrapper pre-assigns this before calling Initialize) + if p.mcpServer == nil { + p.mcpServer = server.NewMCPServer( + "Dynamic MCP Proxy", + "1.0.0", + server.WithToolCapabilities(true), + ) + } // Discover tools from all configured servers log.Println("Discovering tools from remote servers...") @@ -92,16 +96,16 @@ func (p *ProxyServer) Initialize(ctx context.Context) error { // Register tools and create handlers for _, tool := range result.Tools { p.registry.RegisterTool(tool, mcpClient) - + // Create MCP tool definition mcpTool := p.createMCPTool(tool) - - // Create proxy handler - handler := proxy.CreateProxyHandler(mcpClient, tool) - + + // Create proxy handler with optional recorder + handler := proxy.CreateProxyHandler(mcpClient, tool, p.recorderFunc) + // Register with MCP server p.mcpServer.AddTool(mcpTool, handler) - + log.Printf("Registered tool: %s", tool.PrefixedName) } } @@ -177,7 +181,11 @@ func (p *ProxyServer) createAndConnectClient(ctx context.Context, serverName str switch serverConfig.Transport { case "stdio": stdioClient := client.NewStdioClient(serverConfig.Name, serverConfig.Command, serverConfig.Args) - + + // Set inheritance config + inheritCfg := serverConfig.ResolveInheritConfig(p.config.Inherit) + stdioClient.SetInheritConfig(inheritCfg) + // Set environment variables if specified if len(serverConfig.Env) > 0 { var env []string @@ -186,7 +194,7 @@ func (p *ProxyServer) createAndConnectClient(ctx context.Context, serverName str } stdioClient.SetEnvironment(env) } - + mcpClient = stdioClient default: return nil, fmt.Errorf("unsupported transport: %s", serverConfig.Transport) diff --git a/main.go b/main.go index fb2b5fd..95c7596 100644 --- a/main.go +++ b/main.go @@ -186,19 +186,19 @@ func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallTo // runDynamicProxyWithManagement runs the proxy with dynamic management tools func runDynamicProxyWithManagement(configPath, recordFile string) error { ctx := context.Background() - + // Load configuration log.Printf("Loading configuration from: %s", configPath) cfg, err := config.LoadConfig(configPath) if err != nil { return fmt.Errorf("failed to load configuration: %w", err) } - + log.Printf("Configuration loaded: %d servers configured", len(cfg.Servers)) - - // Create dynamic wrapper + + // Create dynamic wrapper (uses mark3labs/mcp-go which works with stdio) wrapper := integration.NewDynamicWrapper(cfg) - + // Enable recording if specified if recordFile != "" { log.Printf("Recording JSON-RPC traffic to: %s", recordFile) @@ -206,7 +206,7 @@ func runDynamicProxyWithManagement(configPath, recordFile string) error { return fmt.Errorf("failed to enable recording: %w", err) } } - + // Initialize with static servers log.Println("Initializing proxy server...") if err := wrapper.Initialize(ctx); err != nil { @@ -216,7 +216,7 @@ func runDynamicProxyWithManagement(configPath, recordFile string) error { } log.Println("Starting with no initial servers - use server_add to add servers dynamically") } - + // Start the server return wrapper.Start() } @@ -232,9 +232,9 @@ func runDynamicProxyServer(configPath string) error { } log.Printf("Configuration loaded: %d servers configured", len(cfg.Servers)) - + // Create dynamic proxy server - proxyServer := integration.NewDynamicProxyServer(&cfg.Proxy) + proxyServer := integration.NewDynamicProxyServer(cfg) // Set up graceful shutdown ctx, cancel := context.WithCancel(context.Background()) diff --git a/mcp-debug b/mcp-debug deleted file mode 100755 index b607c17..0000000 Binary files a/mcp-debug and /dev/null differ diff --git a/proxy/handler.go b/proxy/handler.go index b932c38..7f2af43 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -3,33 +3,55 @@ package proxy import ( "context" "fmt" - + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - + "mcp-debug/client" "mcp-debug/discovery" ) +// RecorderFunc is a function that records JSON-RPC messages with metadata +type RecorderFunc func(direction, messageType, toolName, serverName string, message interface{}) + // CreateProxyHandler creates a handler that forwards tool calls to remote servers -func CreateProxyHandler(mcpClient client.MCPClient, remoteTool discovery.RemoteTool) server.ToolHandlerFunc { +// The optional recorder function enables recording of tool call traffic +func CreateProxyHandler(mcpClient client.MCPClient, remoteTool discovery.RemoteTool, recorder RecorderFunc) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Record the request if recorder is provided + if recorder != nil { + recorder("request", "tool_call", remoteTool.PrefixedName, remoteTool.ServerName, request) + } // Extract arguments from the request args, err := extractArguments(request) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to extract arguments: %v", err)), nil + errResult := mcp.NewToolResultError(fmt.Sprintf("Failed to extract arguments: %v", err)) + if recorder != nil { + recorder("response", "tool_call", remoteTool.PrefixedName, remoteTool.ServerName, errResult) + } + return errResult, nil } - + // Forward the call to the remote server using the original tool name result, err := mcpClient.CallTool(ctx, remoteTool.OriginalName, args) if err != nil { // Wrap error with server context errorMsg := fmt.Sprintf("[%s] %v", remoteTool.ServerName, err) - return mcp.NewToolResultError(errorMsg), nil + errResult := mcp.NewToolResultError(errorMsg) + if recorder != nil { + recorder("response", "tool_call", remoteTool.PrefixedName, remoteTool.ServerName, errResult) + } + return errResult, nil } // Transform the result back to MCP format mcpResult := transformResult(result) + + // Record the response if recorder is provided + if recorder != nil { + recorder("response", "tool_call", remoteTool.PrefixedName, remoteTool.ServerName, mcpResult) + } + return mcpResult, nil } } @@ -117,19 +139,19 @@ func (r *ToolRegistry) GetAllTools() []discovery.RemoteTool { } // CreateHandlerForTool creates a proxy handler for a specific tool -func (r *ToolRegistry) CreateHandlerForTool(prefixedToolName string) (server.ToolHandlerFunc, error) { +func (r *ToolRegistry) CreateHandlerForTool(prefixedToolName string, recorder RecorderFunc) (server.ToolHandlerFunc, error) { // Get tool metadata tool, exists := r.GetTool(prefixedToolName) if !exists { return nil, fmt.Errorf("tool not found: %s", prefixedToolName) } - + // Get associated client mcpClient, exists := r.GetClient(tool.ServerName) if !exists { return nil, fmt.Errorf("client not found for server: %s", tool.ServerName) } - - // Create and return the handler - return CreateProxyHandler(mcpClient, tool), nil + + // Create and return the handler with optional recorder + return CreateProxyHandler(mcpClient, tool, recorder), nil } \ No newline at end of file diff --git a/tests/config-fixtures/test-inherit-advanced.yaml b/tests/config-fixtures/test-inherit-advanced.yaml new file mode 100644 index 0000000..1655215 --- /dev/null +++ b/tests/config-fixtures/test-inherit-advanced.yaml @@ -0,0 +1,78 @@ +# DRAFT TEST FIXTURE - Environment Inheritance +# Status: Not yet validated with real-world MCP servers +# Purpose: Test proxy-level defaults with server-level overrides +# Expected behavior: Verify configuration resolution chain: +# 1. Proxy defaults apply to servers without inherit config +# 2. Servers can override proxy defaults +# 3. mode: none provides maximum isolation +# Created: 2026-01-12 +# Feature: Selective Environment Inheritance (commit 49f5581) + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 + + # Proxy-level inheritance defaults + inherit: + mode: tier1 # Default for all servers + deny: + - SSH_AUTH_SOCK # Block SSH agent by default + - AWS_SESSION_TOKEN + +servers: + # Server 1: Uses proxy defaults (no inherit block) + - name: "test-inherit-proxy-defaults" + prefix: "default" + transport: "stdio" + command: "/usr/bin/env" + timeout: "10s" + # No inherit block = uses proxy.inherit defaults + # + # Expected inherited variables: + # - Tier1 (from proxy default mode: tier1) + # - NOT SSH_AUTH_SOCK (from proxy deny list) + # - NOT AWS_SESSION_TOKEN (from proxy deny list) + # + # Use case: Verify proxy defaults are applied correctly + + # Server 2: Overrides proxy defaults with tier2 + extras + - name: "test-inherit-override-tier2" + prefix: "override" + transport: "stdio" + command: "/usr/bin/env" + timeout: "10s" + inherit: + mode: tier1+tier2 # Override proxy's tier1 + extra: + - PYTHONPATH + - NODE_ENV + deny: + - GITHUB_TOKEN # Add server-specific denial + # + # Expected inherited variables: + # - Tier1 + tier2 (overriding proxy default) + # - PYTHONPATH, NODE_ENV (extras) + # - NOT GITHUB_TOKEN (server-level deny) + # - NOT SSH_AUTH_SOCK, AWS_SESSION_TOKEN (proxy-level deny still applies) + # + # Use case: Verify server config overrides proxy defaults + + # Server 3: Maximum isolation with mode: none + - name: "test-inherit-isolation" + prefix: "isolated" + transport: "stdio" + command: "/usr/bin/env" + timeout: "10s" + inherit: + mode: none # No inheritance at all + env: + # Only these explicit variables are set + ISOLATION_TEST: "true" + SERVER_NAME: "test-inherit-isolation" + # + # Expected inherited variables: + # - NONE from parent environment + # - Only env: block values (ISOLATION_TEST, SERVER_NAME) + # + # Use case: Verify mode: none provides complete isolation diff --git a/tests/config-fixtures/test-inherit-denylist-override.yaml b/tests/config-fixtures/test-inherit-denylist-override.yaml new file mode 100644 index 0000000..0c2ebf6 --- /dev/null +++ b/tests/config-fixtures/test-inherit-denylist-override.yaml @@ -0,0 +1,65 @@ +# DRAFT TEST FIXTURE - Environment Inheritance +# Status: Not yet validated with real-world MCP servers +# Purpose: Test allow_denied_if_explicit flag +# Expected behavior: Explicitly requested variables in 'extra' can override +# deny list when allow_denied_if_explicit is true +# Created: 2026-01-12 +# Feature: Selective Environment Inheritance (commit 49f5581) + +servers: + # Test allow_denied_if_explicit flag with explicit override + - name: "test-inherit-allow-denied" + prefix: "allowdeny" + transport: "stdio" + command: "/usr/bin/env" # Prints environment to verify inheritance + timeout: "10s" + inherit: + mode: tier1 # Baseline variables only + extra: + - HTTP_PROXY # Normally in implicit denylist + - CUSTOM_SECRET # Also in explicit deny list below + deny: + - CUSTOM_SECRET # Explicitly deny this variable + - AWS_ACCESS_KEY_ID + allow_denied_if_explicit: true # Allow extras to override denies + # + # Expected inherited variables: + # - All tier1 variables (PATH, HOME, USER, etc.) + # - HTTP_PROXY (overriding implicit denylist via allow_denied_if_explicit) + # - CUSTOM_SECRET (overriding explicit deny via allow_denied_if_explicit) + # - NOT AWS_ACCESS_KEY_ID (denied but not in extra list) + # + # Security note: This flag should only be used when you trust the server + # and need specific variables from the deny lists (like proxy vars). + # + # Use case: Corporate proxy server that needs HTTP_PROXY but should + # otherwise follow security best practices + + # Control test: Same config without allow_denied_if_explicit + - name: "test-inherit-deny-enforced" + prefix: "denyenforce" + transport: "stdio" + command: "/usr/bin/env" + timeout: "10s" + inherit: + mode: tier1 + extra: + - HTTP_PROXY # Normally in implicit denylist + - CUSTOM_SECRET # Also in explicit deny list + deny: + - CUSTOM_SECRET + - AWS_ACCESS_KEY_ID + # allow_denied_if_explicit: false (default) + # + # Expected inherited variables: + # - All tier1 variables (PATH, HOME, USER, etc.) + # - NOT HTTP_PROXY (implicit denylist blocks it) + # - NOT CUSTOM_SECRET (explicit deny blocks it) + # - NOT AWS_ACCESS_KEY_ID (explicitly denied) + # + # Use case: Verify that without the flag, deny lists are enforced + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 diff --git a/tests/config-fixtures/test-inherit-tier1.yaml b/tests/config-fixtures/test-inherit-tier1.yaml new file mode 100644 index 0000000..d56a402 --- /dev/null +++ b/tests/config-fixtures/test-inherit-tier1.yaml @@ -0,0 +1,50 @@ +# DRAFT TEST FIXTURE - Environment Inheritance +# Status: Not yet validated with real-world MCP servers +# Purpose: Test basic tier1 mode (default behavior) +# Expected behavior: Server should inherit only baseline tier1 variables +# - PATH, HOME, USER, SHELL, LOGNAME +# - LANG, LC_ALL, LC_*, TZ +# - TMPDIR, TEMP, TMP +# Created: 2026-01-12 +# Feature: Selective Environment Inheritance (commit 49f5581) + +servers: + # Test tier1 default inheritance (implicit mode) + - name: "test-inherit-tier1-implicit" + prefix: "tier1" + transport: "stdio" + command: "/usr/bin/env" # Prints environment to verify inheritance + timeout: "10s" + # No inherit block specified = tier1 mode (default) + # + # Expected inherited variables: + # - System basics: PATH, HOME, USER, SHELL, LOGNAME + # - Locale/timezone: LANG, LC_ALL, LC_*, TZ + # - Temp directories: TMPDIR, TEMP, TMP + # + # Expected NOT inherited: + # - Credentials: AWS_*, GITHUB_TOKEN, ANTHROPIC_API_KEY, etc. + # - SSH: SSH_AUTH_SOCK, SSH_AGENT_PID + # - Network: HTTP_PROXY, http_proxy, no_proxy + # - Custom application variables + + # Test explicit tier1 mode with single extra variable + - name: "test-inherit-tier1-explicit" + prefix: "tier1ex" + transport: "stdio" + command: "/usr/bin/env" + timeout: "10s" + inherit: + mode: tier1 # Explicit specification + extra: ["PYTHONPATH"] # Add one additional variable + # + # Expected inherited variables: + # - All tier1 variables (same as above) + # - PYTHONPATH (if set in parent environment) + # + # Use case: Verify explicit tier1 mode works identically to implicit + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3 diff --git a/tests/config-fixtures/test-inherit-tier2.yaml b/tests/config-fixtures/test-inherit-tier2.yaml new file mode 100644 index 0000000..2770f7a --- /dev/null +++ b/tests/config-fixtures/test-inherit-tier2.yaml @@ -0,0 +1,51 @@ +# DRAFT TEST FIXTURE - Environment Inheritance +# Status: Not yet validated with real-world MCP servers +# Purpose: Test tier1+tier2 mode with extras, prefix, and deny list +# Expected behavior: Server should inherit tier1 + tier2 variables, plus: +# - Extra variables: PYTHONPATH, VIRTUAL_ENV +# - Variables matching prefix: TEST_* +# - Except denied: SSH_AUTH_SOCK +# Created: 2026-01-12 +# Feature: Selective Environment Inheritance (commit 49f5581) + +servers: + # Test tier1+tier2 with comprehensive configuration + - name: "test-inherit-tier2-comprehensive" + prefix: "tier2" + transport: "stdio" + command: "/usr/bin/env" # Prints environment to verify inheritance + timeout: "10s" + inherit: + mode: tier1+tier2 # Baseline + network/TLS variables + extra: + - PYTHONPATH # Python module path + - VIRTUAL_ENV # Virtual environment + prefix: + - TEST_ # All TEST_* variables (for testing) + deny: + - SSH_AUTH_SOCK # Block SSH agent even if somehow matched + # + # Expected inherited variables: + # Tier 1 (baseline): + # - PATH, HOME, USER, SHELL, LOGNAME + # - LANG, LC_ALL, LC_*, TZ + # - TMPDIR, TEMP, TMP + # Tier 2 (network/TLS): + # - SSL_CERT_FILE, SSL_CERT_DIR (system TLS certificates) + # - REQUESTS_CA_BUNDLE (Python requests library) + # - CURL_CA_BUNDLE (curl) + # - NODE_EXTRA_CA_CERTS (Node.js) + # Extra: + # - PYTHONPATH (if set in parent) + # - VIRTUAL_ENV (if set in parent) + # Prefix: + # - All TEST_* variables (e.g., TEST_VAR1, TEST_CONFIG, etc.) + # Denied: + # - NOT SSH_AUTH_SOCK (explicitly blocked) + # + # Use case: Comprehensive test of tier2, extras, prefix matching, and deny list + +proxy: + healthCheckInterval: "30s" + connectionTimeout: "10s" + maxRetries: 3