Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/root/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func newMCPCmd() *cobra.Command {
cmd.PersistentFlags().StringVar(&flags.authToken, "auth-token", "", "Bearer token required for HTTP MCP requests; only valid with --http")
cmd.PersistentFlags().BoolVar(&flags.insecureNoAuth, "insecure-no-auth", false, "Allow unauthenticated non-loopback HTTP binding (insecure); only valid with --http")
cmd.PersistentFlags().StringVar(&flags.runConfig.MCPToolName, "tool-name", "", "Override the MCP tool identifier clients call (defaults to agent name); only valid when exposing a single agent")
cmd.PersistentFlags().DurationVar(&flags.runConfig.MCPKeepAlive, "mcp-keepalive", 0, "Interval between MCP keep-alive pings (e.g. 30s); 0 disables keep-alive")
cmd.PersistentFlags().DurationVar(&flags.runConfig.MCPKeepAlive, "mcp-keepalive", 0, "Interval between MCP keep-alive pings (e.g. 30s); 0 disables keep-alive; only valid when serving an agent over stdio (not --http or --attach)")
addRuntimeConfigFlags(cmd, &flags.runConfig)

return cmd
Expand All @@ -68,12 +68,18 @@ func (f *mcpFlags) runMCPCommand(cmd *cobra.Command, args []string) (commandErr
if f.http || f.safety != "" || f.authToken != "" || f.insecureNoAuth {
return errors.New("--http-only safety and authentication flags cannot be used with --attach")
}
if f.runConfig.MCPKeepAlive != 0 {
return errors.New("--mcp-keepalive cannot be used with --attach: the attach proxy ignores runtime configuration")
}
return f.runAttach(ctx)
}

if !f.http && (f.safety != "" || f.authToken != "" || f.insecureNoAuth) {
return errors.New("--safety, --auth-token, and --insecure-no-auth require --http")
}
if f.http && f.runConfig.MCPKeepAlive != 0 {
return errors.New("--mcp-keepalive is not supported with --http: stateless HTTP MCP does not support server-initiated keep-alive pings; use the stdio transport instead")
}
if err := validateSafetyFlag(f.safety); err != nil {
return err
}
Expand Down
46 changes: 46 additions & 0 deletions cmd/root/mcp_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package root

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -37,3 +39,47 @@ func TestMCPHTTPRejectsUnauthenticatedNonLoopbackBind(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "require --auth-token or --insecure-no-auth")
}

func TestMCPKeepAliveRejectedWithHTTP(t *testing.T) {
t.Parallel()

// The stateless HTTP transport (MCP 2026-07-28) rejects server-initiated
// requests, so a keep-alive ping interval must be refused before the
// server starts listening.
cmd := newMCPCmd()
cmd.SetArgs([]string{"agent.yaml", "--http", "--mcp-keepalive", "30s"})
err := cmd.ExecuteContext(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "--mcp-keepalive")
assert.Contains(t, err.Error(), "stateless HTTP")
}

func TestMCPKeepAliveRejectedWithAttach(t *testing.T) {
t.Parallel()

// --attach proxies a running TUI session through its own MCP server,
// which never sees the runtime configuration: the flag must be refused
// rather than silently ignored.
cmd := newMCPCmd()
cmd.SetArgs([]string{"--attach", "--mcp-keepalive", "30s"})
err := cmd.ExecuteContext(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "--mcp-keepalive")
assert.Contains(t, err.Error(), "--attach")
}

func TestMCPKeepAliveAcceptedForStdio(t *testing.T) {
t.Parallel()

// An unparsable agent file makes the stdio path fail during config
// loading, which is past flag validation: the keep-alive flag itself
// must not be rejected without --http.
agentFile := filepath.Join(t.TempDir(), "broken.yaml")
require.NoError(t, os.WriteFile(agentFile, []byte("not: [valid"), 0o600))

cmd := newMCPCmd()
cmd.SetArgs([]string{agentFile, "--mcp-keepalive", "30s"})
err := cmd.ExecuteContext(t.Context())
require.Error(t, err)
assert.NotContains(t, err.Error(), "--mcp-keepalive")
}
2 changes: 1 addition & 1 deletion docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ $ docker agent serve mcp <config> [flags]
| `--auth-token <token>` | (none) | Required Bearer token for HTTP MCP requests. |
| `--insecure-no-auth` | `false` | Permit unauthenticated non-loopback HTTP MCP binding. |
| `-l, --listen <addr>` | `127.0.0.1:8081` | Address to listen on (only used with `--http`). |
| `--mcp-keepalive <dur>`| `0` (disabled) | Interval between MCP keep-alive pings (e.g. `30s`). |
| `--mcp-keepalive <dur>` | `0` (disabled) | Interval between MCP keep-alive pings (e.g. `30s`). Only when serving an agent over stdio — rejected with `--http` (the stateless HTTP transport, MCP `2026-07-28`, has no server-initiated ping) and with `--attach`. |
| `--attach [target]` | (none) | Attach to a running TUI run by pid, address, or session id; given without a value, selects the most recent run. |

All [runtime configuration flags](#runtime-configuration-flags) are also accepted.
Expand Down
4 changes: 3 additions & 1 deletion docs/features/mcp-mode/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ $ docker agent serve mcp ./agent.yaml --http --listen 0.0.0.0:9090 --auth-token
| `--auth-token` | (none) | Require this Bearer token for HTTP requests. Required for non-loopback HTTP unless explicitly overridden. |
| `--insecure-no-auth` | `false` | Permit unauthenticated non-loopback HTTP. Use only behind a trusted authentication boundary. |
| `--safety` | `restricted` | Tool safety policy for HTTP requests. CLI value overrides agent/runtime configuration. |
| `--mcp-keepalive` | `0` | Interval between MCP keep-alive pings (e.g. `30s`); `0` disables keep-alive. |
| `--mcp-keepalive` | `0` | Interval between MCP keep-alive pings (e.g. `30s`); `0` disables keep-alive. Only when serving an agent over stdio — rejected with `--http` and `--attach`. |

Runtime configuration flags such as `--working-dir`, `--env-from-file`, `--models-gateway`, and hook flags are also available — see the [CLI reference](../cli/index.md).

The HTTP transport is **stateless**, per MCP spec revision `2026-07-28`: modern clients negotiate via `server/discover`, no `Mcp-Session-Id` is issued, and only POST requests are served (GET and DELETE answer `405 Method Not Allowed`). Clients speaking older protocol revisions keep working — the legacy `initialize` handshake is accepted with per-request state. However, older stateful clients that depend on a standalone GET stream or session `DELETE` teardown must upgrade to (or switch to) a client compatible with stateless streaming HTTP. Because the stateless transport has no server-initiated ping, `--mcp-keepalive` is rejected together with `--http`; keep-alive remains available on the stdio transport.

## HTTP security

HTTP MCP defaults to loopback binding. A non-loopback `--listen` address requires `--auth-token`; use `--insecure-no-auth` only when a trusted reverse proxy or network boundary authenticates clients. The safety policy is resolved in this order: `--safety`, agent configuration, runtime configuration, then `restricted`. These HTTP-only flags do not affect stdio or `--attach` operation.
Expand Down
26 changes: 22 additions & 4 deletions pkg/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func StartMCPServer(ctx context.Context, agentFilename, agentName string, runCon

// StartHTTPServer starts a streaming HTTP MCP server on the given listener
func StartHTTPServer(ctx context.Context, agentFilename, agentName string, runConfig *config.RuntimeConfig, ln net.Listener, options HTTPOptions) error {
// Fail fast, before any config or team loading: the stateless HTTP
// transport (MCP 2026-07-28) rejects server-initiated requests such as
// ping, so keep-alive can never work here.
if runConfig.MCPKeepAlive != 0 {
return errors.New("MCP keep-alive is not supported over stateless HTTP; use the stdio transport instead")
}

slog.DebugContext(ctx, "Starting HTTP MCP server", "agent", agentFilename, "addr", ln.Addr())

agentSource, err := config.Resolve(agentFilename, nil)
Expand Down Expand Up @@ -98,9 +105,7 @@ func StartHTTPServer(ctx context.Context, agentFilename, agentName string, runCo

fmt.Printf("MCP HTTP server listening on http://%s\n", ln.Addr())

handler := http.Handler(mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
return server
}, nil))
handler := newStreamableHTTPHandler(server)
if options.AuthToken != "" {
handler = httpsec.BearerAuth(options.AuthToken)(handler)
}
Expand Down Expand Up @@ -135,6 +140,16 @@ func StartHTTPServer(ctx context.Context, agentFilename, agentName string, runCo
}
}

// newStreamableHTTPHandler builds the streamable HTTP handler used in
// production. Stateless mode implements the sessionless MCP 2026-07-28
// transport: no Mcp-Session-Id header, GET/DELETE rejected with 405, and
// request-local state synthesized for legacy initialize-based clients.
func newStreamableHTTPHandler(server *mcp.Server) http.Handler {
return mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{Stateless: true})
}

func createMCPServer(ctx context.Context, agentFilename, agentName string, runConfig *config.RuntimeConfig) (*mcp.Server, func(), error) {
agentSource, err := config.Resolve(agentFilename, nil)
if err != nil {
Expand All @@ -161,7 +176,10 @@ func createMCPServer(ctx context.Context, agentFilename, agentName string, runCo
}

func createMCPServerForTeam(ctx context.Context, t *team.Team, agentFilename, agentName string, runConfig *config.RuntimeConfig, safety session.SafetyPolicy) (*mcp.Server, error) {
// The SDK only starts keep-alive when KeepAlive > 0.
// The SDK only starts keep-alive when KeepAlive > 0. StartHTTPServer (and
// the CLI, for early UX) rejects a nonzero keep-alive, so this only ever
// takes effect for stdio: the stateless HTTP transport (MCP 2026-07-28)
// rejects server-initiated requests such as ping.
server := mcp.NewServer(&mcp.Implementation{
Name: "docker agent",
Version: version.Version,
Expand Down
Loading
Loading