Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
56e0fe8
docs: plan the Kenn Forge MCP handoff
mariusvniekerk Aug 7, 2026
6ce096f
test: restore enforced testify helper style
mariusvniekerk Aug 7, 2026
e29b67c
feat: expose provider-neutral workflow state
mariusvniekerk Aug 7, 2026
d769922
feat: expose cached maintainer workflows through MCP
mariusvniekerk Aug 7, 2026
e9dd9be
fix: make MCP automation evidence-safe
mariusvniekerk Aug 7, 2026
c01bbb1
feat: prevent duplicate initial agent prompts
mariusvniekerk Aug 7, 2026
29bc879
fix: keep MCP handoff retries evidence-safe
mariusvniekerk Aug 7, 2026
2fe15c9
feat: expose live coding sessions for workspaces
mariusvniekerk Aug 7, 2026
3c3f7f5
fix: close MCP handoff evidence gaps
mariusvniekerk Aug 7, 2026
035b1ad
feat: submit one verified initial agent prompt
mariusvniekerk Aug 7, 2026
d8cd6d9
feat: let MCP discover live agent handoff targets
mariusvniekerk Aug 7, 2026
d0679dd
feat: hand MCP work to live coding agents
mariusvniekerk Aug 7, 2026
c490c3f
fix: preserve total agent handoff timeouts
mariusvniekerk Aug 7, 2026
5177802
fix: target the current MCP protocol
mariusvniekerk Aug 8, 2026
60eabc2
fix: keep MCP handoff failures evidence-safe
mariusvniekerk Aug 8, 2026
f977b78
fix: rebase MCP handoff on current repository identity
mariusvniekerk Aug 13, 2026
ab92ec3
test: stabilize MCP handoff timeout evidence
mariusvniekerk Aug 13, 2026
5bf29c1
refactor: keep initial agent prompts in memory
mariusvniekerk Aug 13, 2026
c05d80a
test: make MCP CI checks deterministic
mariusvniekerk Aug 13, 2026
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ only routes to them.
| Frontend interaction, route state, persistence, or input semantics | `context/ui-interaction-contracts.md` |
| Phone routes, narrow layouts, or touch UX | `context/mobile-ux.md` |
| Workflow or terminal panel interaction models | `context/vscode-workflow-panel-interaction-spec.md` |
| MCP companion tools, transports, safety boundary, or agent handoff | `context/mcp-server.md` |
| Workspace APIs, creation, item identity, lifecycle hooks, or generated launch context | `context/workspace-apis.md` |
| Workspace deletion, runtime sessions, tmux, or terminal UI | `context/workspace-runtime-lifecycle.md` |
| Repository source-browser routes, clones, refs, or previews | `context/repository-source-browser.md` |
Expand Down
5 changes: 5 additions & 0 deletions cmd/kenn-forge/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type cliOptions struct {
Stderr io.Writer
RunServer serve.Runner
DaemonRunner daemonCommandRunner
MCPRunner mcpRunner
}

func newRootCommand(opts cliOptions) *cobra.Command {
Expand All @@ -35,6 +36,9 @@ func newRootCommand(opts cliOptions) *cobra.Command {
if opts.DaemonRunner == nil {
opts.DaemonRunner = newDaemonLifecycle(defaultDaemonLifecycleDeps())
}
if opts.MCPRunner == nil {
opts.MCPRunner = runMCP
}

root := &cobra.Command{
Use: "kenn-forge",
Expand Down Expand Up @@ -65,6 +69,7 @@ func newRootCommand(opts cliOptions) *cobra.Command {
newArchiveCommand(opts.Stdout, time.Now),
newAgentHookCommand(opts.Stdin, opts.Stdout),
newDaemonCommand(opts.DaemonRunner),
newMCPCommand(opts.MCPRunner, opts.Stdin, opts.Stdout),
newPtyOwnerCommand(),
serve.NewCommand(opts.RunServer),
)
Expand Down
1 change: 0 additions & 1 deletion cmd/kenn-forge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,6 @@ func run(opts serve.Options) error {
if err != nil {
return fmt.Errorf("open database: %w", err)
}

tokenSources := tokenauth.NewSourceSet(tokenauth.Options{
GitHubCLI: config.GitHubCLITokenForHost,
GitHubApp: func(
Expand Down
3 changes: 2 additions & 1 deletion cmd/kenn-forge/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ func TestRootHelpListsEveryPublicCommandWithoutStartingServer(t *testing.T) {

for _, name := range []string{
"activity", "agent-hook", "api", "archive", "config", "docs",
"daemon", "issues", "pulls", "quickstart", "rate-limits", "repo-summaries",
"daemon", "issues", "mcp", "pulls", "quickstart", "rate-limits", "repo-summaries",
"repos", "serve", "stacks", "sync", "version", "workspaces",
} {
assert.Contains(stdout.String(), name)
Expand Down Expand Up @@ -947,6 +947,7 @@ func TestRootNestedHelpExposesCommandFlags(t *testing.T) {
{name: "daemon status", args: []string{"daemon", "status", "--help"}, want: []string{"--config", "--json"}},
{name: "daemon stop", args: []string{"daemon", "stop", "--help"}, want: []string{"--config"}},
{name: "daemon restart", args: []string{"daemon", "restart", "--help"}, want: []string{"--config"}},
{name: "mcp", args: []string{"mcp", "--help"}, want: []string{"--config", "--transport", "--addr", "--http-token-env", "--daemon-timeout"}},
{name: "serve", args: []string{"serve", "--help"}, want: []string{"--config", "--pprof-addr"}},
{name: "api", args: []string{"api", "--help"}, want: []string{"list", "--config", "-d", "-i", "--timeout"}},
}
Expand Down
98 changes: 98 additions & 0 deletions cmd/kenn-forge/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"time"

"github.com/spf13/cobra"
"go.kenn.io/forge/internal/config"
"go.kenn.io/forge/internal/mcpserver"
)

type mcpRunner func(
context.Context,
mcpserver.Options,
io.Reader,
io.Writer,
) error

func newMCPCommand(run mcpRunner, stdin io.Reader, stdout io.Writer) *cobra.Command {
opts := mcpserver.Options{
ConfigPath: config.DefaultConfigPath(),
Transport: "stdio",
Addr: "127.0.0.1:0",
DaemonTimeout: 10 * time.Second,
Version: version,
}
cmd := &cobra.Command{
Use: "mcp",
Short: "Expose cached maintainer workflows to MCP clients",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if opts.DaemonTimeout <= 0 {
return fmt.Errorf("--daemon-timeout must be positive")
}
switch opts.Transport {
case "stdio":
if cmd.Flags().Changed("addr") {
return fmt.Errorf("--addr requires --transport=http")
}
if cmd.Flags().Changed("http-token-env") {
return fmt.Errorf("--http-token-env requires --transport=http")
}
case "http":
_, port, err := net.SplitHostPort(opts.Addr)
portNumber, portErr := strconv.Atoi(port)
if !cmd.Flags().Changed("addr") || err != nil || !asciiDigits(port) || portErr != nil ||
portNumber < 1 || portNumber > 65535 {
return fmt.Errorf("--addr with an explicit nonzero port is required for --transport=http")
}
default:
return fmt.Errorf("unsupported transport %q: use stdio or http", opts.Transport)
}
return run(cmd.Context(), opts, stdin, stdout)
},
}
cmd.Flags().StringVar(&opts.ConfigPath, "config", opts.ConfigPath, "path to config file")
cmd.Flags().StringVar(&opts.Transport, "transport", opts.Transport, "MCP transport: stdio or http")
cmd.Flags().StringVar(&opts.Addr, "addr", opts.Addr, "HTTP listen address (http transport only)")
cmd.Flags().StringVar(&opts.HTTPTokenEnv, "http-token-env", "", "environment variable holding the HTTP bearer token")
cmd.Flags().DurationVar(&opts.DaemonTimeout, "daemon-timeout", opts.DaemonTimeout, "per-request daemon timeout")
return cmd
}

func asciiDigits(value string) bool {
if value == "" {
return false
}
for index := range value {
if value[index] < '0' || value[index] > '9' {
return false
}
}
return true
}

func runMCP(
ctx context.Context,
opts mcpserver.Options,
stdin io.Reader,
stdout io.Writer,
) error {
srv, err := mcpserver.New(opts)
if err != nil {
return err
}
var runErr error
if opts.Transport == "http" {
runErr = srv.RunHTTP(ctx)
} else {
runErr = srv.RunStdio(ctx, stdin, stdout)
}
return errors.Join(runErr, srv.Close())
}
186 changes: 186 additions & 0 deletions cmd/kenn-forge/mcp_cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package main

import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"

"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/forge/internal/mcpserver"
)

func TestMCPCommandIsPublicAndOwnsItsFlags(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
root := newRootCommand(cliOptions{MCPRunner: func(
context.Context, mcpserver.Options, io.Reader, io.Writer,
) error {
return nil
}})

cmd, _, err := root.Find([]string{"mcp"})
require.NoError(err)
require.NotNil(cmd)
assert.False(cmd.Hidden)

var flags []string
cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) {
flags = append(flags, flag.Name)
})
assert.ElementsMatch([]string{
"addr", "config", "daemon-timeout", "http-token-env", "transport",
}, flags)
}

func TestMCPCommandPassesEveryFlagAndCLIStreamToRunner(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
stdin := strings.NewReader("client input")
var stdout bytes.Buffer
var received mcpserver.Options
var receivedIn io.Reader
var receivedOut io.Writer
root := newRootCommand(cliOptions{
Stdin: stdin, Stdout: &stdout, Stderr: io.Discard,
MCPRunner: func(
_ context.Context,
opts mcpserver.Options,
in io.Reader,
out io.Writer,
) error {
received, receivedIn, receivedOut = opts, in, out
return nil
},
})
root.SetArgs([]string{
"mcp", "--config", "/tmp/forge.toml", "--transport", "http",
"--addr", "127.0.0.1:8092", "--http-token-env", "KENN_FORGE_MCP_TOKEN",
"--daemon-timeout", "3s",
})

require.NoError(root.Execute())
assert.Equal("/tmp/forge.toml", received.ConfigPath)
assert.Equal("http", received.Transport)
assert.Equal("127.0.0.1:8092", received.Addr)
assert.Equal("KENN_FORGE_MCP_TOKEN", received.HTTPTokenEnv)
assert.Equal(3*time.Second, received.DaemonTimeout)
assert.Same(stdin, receivedIn)
assert.Same(&stdout, receivedOut)
}

func TestMCPCommandDefaultsToStdioAndRejectsUnknownTransport(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
var received mcpserver.Options
runs := 0
runner := func(
_ context.Context,
opts mcpserver.Options,
_ io.Reader,
_ io.Writer,
) error {
runs++
received = opts
return nil
}

cmd := newMCPCommand(runner, strings.NewReader(""), io.Discard)
cmd.SetArgs(nil)
require.NoError(cmd.Execute())
assert.Equal("stdio", received.Transport)
assert.Equal("127.0.0.1:0", received.Addr)
assert.Equal(10*time.Second, received.DaemonTimeout)

cmd = newMCPCommand(runner, strings.NewReader(""), io.Discard)
cmd.SetArgs([]string{"--transport", "invalid"})
err := cmd.Execute()
require.Error(err)
assert.Contains(err.Error(), "unsupported transport")
assert.Equal(1, runs)
}

func TestMCPCommandRejectsFlagsThatCannotAffectExecution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "stdio address",
args: []string{"--addr", "127.0.0.1:8092"},
want: "--addr requires --transport=http",
},
{
name: "stdio token environment",
args: []string{"--http-token-env", "KENN_FORGE_MCP_TOKEN"},
want: "--http-token-env requires --transport=http",
},
{
name: "zero daemon timeout",
args: []string{"--daemon-timeout", "0s"},
want: "--daemon-timeout must be positive",
},
{
name: "negative daemon timeout",
args: []string{"--daemon-timeout=-1s"},
want: "--daemon-timeout must be positive",
},
{
name: "http implicit ephemeral address",
args: []string{"--transport", "http"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http explicit ephemeral address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:0"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http equivalent zero address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:00"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http negative address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:-1"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http oversized address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:65536"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http nonnumeric address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:http"},
want: "--addr with an explicit nonzero port is required",
},
{
name: "http signed address",
args: []string{"--transport", "http", "--addr", "127.0.0.1:+8080"},
want: "--addr with an explicit nonzero port is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runs := 0
cmd := newMCPCommand(func(
context.Context, mcpserver.Options, io.Reader, io.Writer,
) error {
runs++
return nil
}, strings.NewReader(""), io.Discard)
cmd.SetArgs(tc.args)

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
assert.Zero(t, runs)
})
}
}
Loading
Loading