Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e6e82e8
chore(deps): add modelcontextprotocol/go-sdk
skevetter Jun 3, 2026
910ca3b
feat(mcp): add mcp serve command skeleton
skevetter Jun 3, 2026
4304a4d
feat(mcp): add BoundedBuffer for exec output capture
skevetter Jun 3, 2026
1f19642
refactor(workspace): move exec helpers into pkg/workspace for reuse
skevetter Jun 3, 2026
baad611
feat(workspace): add ExecOneShot capture-based exec entry point
skevetter Jun 3, 2026
ddfe75e
feat(mcp): map CLI errors to MCP structured payloads
skevetter Jun 3, 2026
8289db1
feat(mcp): register workspace_list and workspace_status tools
skevetter Jun 3, 2026
40f5954
feat(mcp): add workspace start/stop/delete/create tools
skevetter Jun 3, 2026
e0da083
feat(mcp): add workspace_exec tool
skevetter Jun 3, 2026
b386478
feat(mcp): add provider list/add/delete/use tools
skevetter Jun 3, 2026
70351a1
test(mcp): add integration test for tool registration
skevetter Jun 3, 2026
f1a38ca
refactor(workspace): drop re-export shims and tidy ExecOneShot
skevetter Jun 3, 2026
471eaac
fix(mcp): protect stdio framing, flag injection, error logging, panic…
skevetter Jun 3, 2026
cbda0de
fix(workspace): distinguish context cancellation from deadline in Exe…
skevetter Jun 3, 2026
99fa02a
fix(mcp): use --flag=value form to prevent flag injection via values
skevetter Jun 3, 2026
5623d60
fix(workspace): stop WithSignals from leaking a goroutine per call
skevetter Jun 3, 2026
3a73782
style(mcp): tighten serve stdout-redirect comment
skevetter Jun 3, 2026
50d0f4b
fix(mcp): round sub-second exec timeouts up instead of truncating to …
skevetter Jun 4, 2026
34de3eb
fix(mcp): validate non-empty required strings in provider handlers
skevetter Jun 4, 2026
6d65b97
fix(mcp): ensure workspace_start refuses to create a workspace
skevetter Jun 4, 2026
0e77736
test(mcp): drive the integration test through ServeCmd.registerTools
skevetter Jun 4, 2026
21bcbeb
fix(mcp): close provider_add --name flag injection gap
skevetter Jun 4, 2026
2be2827
fix(workspace): don't claim TimedOut when the parent context cancelled
skevetter Jun 4, 2026
f7ac471
fix(mcp): require non-empty name on workspace_stop and workspace_delete
skevetter Jun 4, 2026
9b62676
refactor(mcp,workspace): tighten exec_oneshot internals and exec outp…
skevetter Jun 4, 2026
91a375c
refactor(mcp): drop cobra round-trip for provider_delete and provider…
skevetter Jun 4, 2026
d145014
refactor(workspace,mcp): expose up.RunFromOptions and drop cobra roun…
skevetter Jun 4, 2026
56fc1c1
refactor(workspace,mcp): plumb output writer through up.go instead of…
skevetter Jun 4, 2026
1c90089
refactor(mcp): switch BoundedBuffer from mid-truncation to tail-only
skevetter Jun 4, 2026
20a1a5a
refactor(mcp): collapse repeated opOK handler boilerplate
skevetter Jun 4, 2026
556a22a
fix(workspace): tighten WithSignals cleanup to handle in-flight signals
skevetter Jun 4, 2026
acd644d
docs(mcp): document TOCTOU window in workspace_start
skevetter Jun 4, 2026
9e0200f
fix(workspace): propagate up.Options.Provider through LoadConfig
skevetter Jun 4, 2026
aa1187b
fix(mcp): preserve classified error in execOutput on partial-output f…
skevetter Jun 4, 2026
86f52c7
docs(mcp): tighten comments that referenced rot-prone context
skevetter Jun 4, 2026
913146d
style(mcp,workspace): trim wordy comments
skevetter Jun 4, 2026
b9b1123
fix(workspace): preserve CLI defaults for *bool flags in RunFromOptions
skevetter Jun 4, 2026
c7aab7b
fix(mcp): preserve panic visibility through SDK SetError
skevetter Jun 4, 2026
d63c82c
fix(workspace): wrap non-ExitError exec failures with container context
skevetter Jun 4, 2026
eafe538
Merge origin/main into explore/mcp-server-command
skevetter Jun 4, 2026
55f6771
fix(workspace): honor --docker-path in workspace-folder exec branch
skevetter Jun 4, 2026
4e845c9
fix(config): honor --docker-path in resolveConfigFromIDLabels
skevetter Jun 4, 2026
446634f
refactor(workspace): fold --docker-path override into ResolveDockerCo…
skevetter Jun 4, 2026
ef4c2f7
refactor(workspace): introduce ContainerRuntime interface to abstract…
skevetter Jun 4, 2026
9c8ed3a
refactor(workspace): collapse exec helpers, runtime, and ExecOneShot …
skevetter Jun 4, 2026
aa7a292
style(workspace): drop section dividers and tighten exec.go comments
skevetter Jun 4, 2026
2a761ae
style: drop first-person voice from comments
skevetter Jun 4, 2026
0f6337d
style(workspace): drop obvious comments on resolvedExecTarget and Doc…
skevetter Jun 4, 2026
e7a1a3f
style(workspace): trim excessive comments in up.go
skevetter Jun 4, 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
5 changes: 3 additions & 2 deletions cmd/config/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/output"
"github.com/devsy-org/devsy/pkg/types"
pkgworkspace "github.com/devsy-org/devsy/pkg/workspace"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -143,7 +144,7 @@ func (cmd *ApplyCmd) resolveDockerPath() string {
if cmd.DockerPath != "" {
return cmd.DockerPath
}
return workspace.DefaultDockerCommand
return pkgworkspace.DefaultDockerCommand
}

func (cmd *ApplyCmd) inspectRunningContainer(
Expand All @@ -159,7 +160,7 @@ func (cmd *ApplyCmd) inspectRunningContainer(
}

containerDetails := &details[0]
if !strings.EqualFold(containerDetails.State.Status, workspace.ContainerStatusRunning) {
if !strings.EqualFold(containerDetails.State.Status, pkgworkspace.ContainerStatusRunning) {
return nil, fmt.Errorf(
"container %s is not running (status: %s)",
cmd.Container,
Expand Down
2 changes: 1 addition & 1 deletion cmd/config/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"testing"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/cmd/workspace"
"github.com/devsy-org/devsy/pkg/workspace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down
9 changes: 4 additions & 5 deletions cmd/config/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import (
"path/filepath"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/cmd/workspace"
devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/devcontainer/metadata"
"github.com/devsy-org/devsy/pkg/docker"
pkgworkspace "github.com/devsy-org/devsy/pkg/workspace"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -216,7 +216,7 @@ func (cmd *ReadCmd) resolveConfigFromContainer(ctx context.Context) (
string,
error,
) {
dockerCommand := workspace.DefaultDockerCommand
dockerCommand := pkgworkspace.DefaultDockerCommand
if cmd.DockerPath != "" {
dockerCommand = cmd.DockerPath
}
Expand Down Expand Up @@ -262,9 +262,8 @@ func (cmd *ReadCmd) resolveConfigFromIDLabels(ctx context.Context) (
string,
error,
) {
containerDetails, err := workspace.FindRunningContainer(
ctx, workspace.DefaultDockerCommand, "", cmd.IDLabels,
)
runtime := pkgworkspace.NewDockerRuntime(nil, cmd.DockerPath)
containerDetails, err := runtime.FindRunning(ctx, "", cmd.IDLabels)
if err != nil {
return nil, "", err
}
Expand Down
19 changes: 8 additions & 11 deletions cmd/internal/runusercommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ func (cmd *RunUserCommandsCmd) resolveDockerPath() string {
if cmd.DockerPath != "" {
return cmd.DockerPath
}
return workspace.DefaultDockerCommand
return workspace2.DefaultDockerCommand
}

func (cmd *RunUserCommandsCmd) inspectRunningContainer(
Expand All @@ -252,7 +252,7 @@ func (cmd *RunUserCommandsCmd) inspectRunningContainer(
}

containerDetails := &details[0]
if !strings.EqualFold(containerDetails.State.Status, workspace.ContainerStatusRunning) {
if !strings.EqualFold(containerDetails.State.Status, workspace2.ContainerStatusRunning) {
errMsg := fmt.Sprintf(
"container %s is not running (status: %s)",
cmd.ContainerID,
Expand Down Expand Up @@ -332,20 +332,17 @@ func (cmd *RunUserCommandsCmd) resolveContainer(
}

workspaceConfig := client.WorkspaceConfig()
dockerCommand := workspace.ResolveDockerCommand(workspaceConfig)
if cmd.DockerPath != "" {
dockerCommand = cmd.DockerPath
}
runtime := workspace2.NewDockerRuntime(workspaceConfig, cmd.DockerPath)

containerDetails, err := workspace.FindRunningContainer(
ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels,
containerDetails, err := runtime.FindRunning(
ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels,
)
if err != nil {
_ = devcconfig.WriteErrorJSON(os.Stderr, err.Error())
return nil, nil, err
}

result := workspace.LoadExecResult(workspaceConfig, containerDetails)
result := workspace2.LoadExecResult(workspaceConfig, containerDetails)
if result == nil || result.MergedConfig == nil {
_ = devcconfig.WriteErrorJSON(
os.Stderr,
Expand All @@ -369,10 +366,10 @@ func (cmd *RunUserCommandsCmd) resolveContainer(

params := &workspace.LifecycleExecParams{
Ctx: ctx,
Helper: &docker.DockerHelper{DockerCommand: dockerCommand},
Helper: &docker.DockerHelper{DockerCommand: runtime.DockerCommand()},
ContainerID: containerDetails.ID,
EnvArgs: envArgs,
Workdir: workspace.ResolveExecWorkdir(result, client.Workspace()),
Workdir: workspace2.ResolveExecWorkdir(result, client.Workspace()),
User: devcconfig.GetRemoteUser(result),
}
return params, result, nil
Expand Down
51 changes: 51 additions & 0 deletions cmd/mcp/buffer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package mcp

import "fmt"

// BoundedBuffer is an io.Writer that keeps only the last cap bytes written.
// Tail retention beats mid-truncation for command output because endings carry
// exit status, errors, and final state.
type BoundedBuffer struct {
cap int
buf []byte
written int64
}

// NewBoundedBuffer returns a BoundedBuffer with the given capacity (minimum 64).
func NewBoundedBuffer(cap int) *BoundedBuffer {
if cap < 64 {
cap = 64
}
return &BoundedBuffer{cap: cap, buf: make([]byte, 0, cap)}
}

func (b *BoundedBuffer) Write(p []byte) (int, error) {
n := len(p)
b.written += int64(n)
if len(p) >= b.cap {
// Incoming chunk fills or exceeds cap — keep only the last cap bytes.
b.buf = append(b.buf[:0], p[len(p)-b.cap:]...)
return n, nil
}
if len(b.buf)+len(p) > b.cap {
drop := len(b.buf) + len(p) - b.cap
b.buf = b.buf[drop:]
}
b.buf = append(b.buf, p...)
return n, nil
}

// Truncated reports whether more bytes were written than the buffer can hold.
func (b *BoundedBuffer) Truncated() bool { return b.written > int64(b.cap) }

// BytesWritten returns the total number of bytes written, including dropped ones.
func (b *BoundedBuffer) BytesWritten() int64 { return b.written }

// String returns the buffered content. When truncated, a marker showing how
// many bytes were dropped is prepended so callers know output is incomplete.
func (b *BoundedBuffer) String() string {
if !b.Truncated() {
return string(b.buf)
}
return fmt.Sprintf("... [%d bytes dropped] ...\n%s", b.written-int64(len(b.buf)), b.buf)
}
87 changes: 87 additions & 0 deletions cmd/mcp/buffer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package mcp

import (
"strings"
"testing"
)

func TestBoundedBuffer_NoTruncation(t *testing.T) {
b := NewBoundedBuffer(100)
_, _ = b.Write([]byte("hello"))
if got := b.String(); got != "hello" {
t.Fatalf("got %q", got)
}
if b.Truncated() {
t.Fatal("expected not truncated")
}
}

func TestBoundedBuffer_TruncatesHead(t *testing.T) {
// Write 130 bytes into a cap-64 buffer: only the last 64 bytes (tail) should survive.
b := NewBoundedBuffer(64)
_, _ = b.Write([]byte(strings.Repeat("a", 66) + strings.Repeat("b", 64)))
s := b.String()
if !b.Truncated() {
t.Fatal("expected truncated")
}
if !strings.Contains(s, "bytes dropped") {
t.Fatalf("missing drop marker: %q", s)
}
// The tail (last 64 bytes, all 'b') should be preserved.
if !strings.HasSuffix(s, strings.Repeat("b", 64)) {
t.Fatalf("tail not preserved: %q", s)
}
// The head ('a' bytes) should be dropped.
if strings.Contains(s, "aaaa") {
t.Fatalf("head should be dropped but was retained: %q", s)
}
}

func TestBoundedBuffer_MultipleWritesAccumulate(t *testing.T) {
b := NewBoundedBuffer(64) // min cap is 64
for range 5 {
_, _ = b.Write([]byte(strings.Repeat("x", 20)))
}
// 100 bytes written into cap 64: should be truncated.
if !b.Truncated() {
t.Fatal("expected truncated after 100 bytes into cap 64")
}
}

func TestBoundedBuffer_TailPreservedAcrossSmallWrites(t *testing.T) {
// Write many small chunks; the buffer should hold the most recent cap bytes.
b := NewBoundedBuffer(64)
for i := range 200 {
_, _ = b.Write([]byte{byte('a' + (i % 26))})
}
if !b.Truncated() {
t.Fatal("expected truncated")
}
s := b.String()
// The last 64 chars of the 200-char sequence.
var want strings.Builder
for i := 136; i < 200; i++ {
want.WriteString(string([]byte{byte('a' + (i % 26))}))
}
if !strings.HasSuffix(s, want.String()) {
t.Fatalf(
"tail not preserved: got suffix %q, want %q",
s[len(s)-len(want.String()):],
want.String(),
)
}
}

func TestBoundedBuffer_LargeChunkOverwrite(t *testing.T) {
// A single write larger than cap should keep only the last cap bytes.
b := NewBoundedBuffer(64)
data := strings.Repeat("a", 30) + strings.Repeat("b", 64)
_, _ = b.Write([]byte(data))
s := b.String()
if !b.Truncated() {
t.Fatal("expected truncated")
}
if !strings.HasSuffix(s, strings.Repeat("b", 64)) {
t.Fatalf("last cap bytes not preserved: %q", s)
}
}
39 changes: 39 additions & 0 deletions cmd/mcp/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package mcp

import (
"errors"

cliErrors "github.com/devsy-org/devsy/pkg/errors"
"github.com/devsy-org/devsy/pkg/workspace"
)

// ErrorPayload is the JSON shape attached to MCP tool errors so the agent gets
// the same structured information the Devsy CLI shows humans (code, hint, doc URL).
type ErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
DocURL string `json:"doc_url,omitempty"`
}

// ClassifyError converts any error returned by an MCP handler into a structured
// payload using the same classifier the CLI uses.
func ClassifyError(err error) ErrorPayload {
if err == nil {
return ErrorPayload{}
}
classified := cliErrors.Classify(err, cliErrors.ClassifyContext{})
code := "internal_error"
if classified.Code != "" {
code = string(classified.Code)
}
if errors.Is(err, workspace.ErrWorkspaceNotFound) {
code = "workspace_not_found"
}
return ErrorPayload{
Code: code,
Message: err.Error(),
Hint: classified.Hint,
DocURL: classified.DocURL,
}
}
12 changes: 12 additions & 0 deletions cmd/mcp/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package mcp

import sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"

// opResultHandler is the standard (errorResult | opOK) wrapper for void operations.
// It calls fn and returns either an error result or opOK{OK: true}.
func opResultHandler(fn func() error) (*sdkmcp.CallToolResult, opOK, error) {
if err := fn(); err != nil {
return errorResult(err), opOK{}, nil
}
return nil, opOK{OK: true}, nil
}
41 changes: 41 additions & 0 deletions cmd/mcp/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package mcp

import (
"errors"
"testing"
)

func TestOpResultHandler_Success(t *testing.T) {
result, ok, err := opResultHandler(func() error { return nil })
if err != nil {
t.Fatalf("unexpected go error: %v", err)
}
if result != nil {
t.Fatalf("success path must return nil *CallToolResult, got %+v", result)
}
if !ok.OK {
t.Fatalf("success path must report ok.OK=true")
}
}

func TestOpResultHandler_Error(t *testing.T) {
sentinel := errors.New("boom")
result, ok, err := opResultHandler(func() error { return sentinel })
if err != nil {
t.Fatalf(
"opResultHandler must always return nil go-error so the SDK uses our *CallToolResult, got %v",
err,
)
}
if result == nil {
t.Fatalf(
"error path must return a non-nil *CallToolResult so the SDK reports IsError to the client",
)
}
if !result.IsError {
t.Fatalf("error path must mark the result IsError=true")
}
if ok.OK {
t.Fatalf("error path must leave ok zero")
}
}
16 changes: 16 additions & 0 deletions cmd/mcp/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package mcp

import (
"github.com/devsy-org/devsy/cmd/flags"
"github.com/spf13/cobra"
)

// NewMCPCmd builds the 'devsy mcp' parent command.
func NewMCPCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Run Devsy as a Model Context Protocol server",
}
cmd.AddCommand(NewServeCmd(globalFlags))
return cmd
}
Loading
Loading