Skip to content
Merged
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
103 changes: 102 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,79 @@ func NewMCPServer() *server.MCPServer {
return newMCPServer(sandbox, serveOptions{})
}

// backgroundControlTools are the tools that follow and stop background
// commands. A client that runs background commands as MCP tasks does both
// through the task itself, so they are hidden from it (hideBackgroundControls).
var backgroundControlTools = map[string]bool{"kill_shell": true, "list_shells": true}

// taskOutputTailBytes bounds how much of a background command's output the
// result of its MCP task carries; the rest stays in its output file.
const taskOutputTailBytes = 8 << 10

// clientRunsTasks reports whether the request comes from a client that
// declared the MCP tasks extension (io.modelcontextprotocol/tasks) in this
// request's capabilities. Such a client gets background commands as MCP
// tasks: it follows them, is told when they finish, and stops them itself.
func clientRunsTasks(ctx context.Context) bool {
info := server.RequestProtocolInfoFromContext(ctx)
return info != nil && info.Modern && info.ClientCapabilities.HasExtension(mcp.ExtensionTasks)
}

// backgroundAsTask reports whether a bash call runs as an MCP task: a
// run_in_background call from a client that runs tasks. It is the server's
// per-request task decision (server.WithTaskDecision) and how the handler
// tells a task run from a plain call, so the two cannot disagree. Config
// requests never run in the background, so they stay plain calls and are
// refused at once.
func (o serveOptions) backgroundAsTask(ctx context.Context, request mcp.CallToolRequest) bool {
if request.Params.Name != "bash" || !clientRunsTasks(ctx) {
return false
}
args, _ := request.Params.Arguments.(map[string]any)
command, background, ok := bashToolArgs(args)
if !ok || !background {
return false
}
if o.configRequests {
if _, isConfig := configrequest.Parse(command); isConfig {
return false
}
}
return true
}

// hideBackgroundControls drops kill_shell and list_shells for a client that
// runs background commands as MCP tasks. The filter also applies to
// tools/call, so such a client cannot call them either.
func hideBackgroundControls(ctx context.Context, tools []mcp.Tool) []mcp.Tool {
if !clientRunsTasks(ctx) {
return tools
}
out := tools[:0:0]
for _, t := range tools {
if !backgroundControlTools[t.Name] {
out = append(out, t)
}
}
return out
}

func newMCPServer(sandbox *bash_sandboxed.Sandbox, opts serveOptions) *server.MCPServer {
s := server.NewMCPServer(
"lite-sandbox",
"0.1.0",
// Background commands run as MCP tasks for clients that declare the
// tasks extension; every other call is answered directly.
server.WithTaskCapabilities(false, true, true),
server.WithTasksExtension(),
server.WithTaskDecision(opts.backgroundAsTask),
server.WithToolFilter(hideBackgroundControls),
)

bashTool := mcp.NewTool(
"bash",
mcp.WithDescription("Execute a bash command in a lightweight sandbox. The command is parsed and validated before execution.\n\nSet run_in_background to true to start a long-running command without blocking; it returns a shell id and the path of a file its output is written to. Read that file with this tool (e.g. `tail -n 50 <file>`), check whether the command has finished with the list_shells tool, and stop it with the kill_shell tool."),
mcp.WithDescription("Execute a bash command in a lightweight sandbox. The command is parsed and validated before execution.\n\nSet run_in_background to true to start a long-running command without blocking. If your client runs it as a background task, you are notified with its exit code, output file, and the end of its output when it finishes. Otherwise the call returns a shell id and the path of a file its output is written to: read that file with this tool (e.g. `tail -n 50 <file>`), check whether the command has finished with the list_shells tool, and stop it with the kill_shell tool."),
mcp.WithTaskSupport(mcp.TaskSupportOptional),
mcp.WithString("command",
mcp.Description("The bash command to execute"),
mcp.Required(),
Expand Down Expand Up @@ -123,10 +187,14 @@ func newMCPServer(sandbox *bash_sandboxed.Sandbox, opts serveOptions) *server.MC
ctx = opts.approveCall(ctx, sandbox, cwd, command)

if runInBackground {
asTask := opts.backgroundAsTask(ctx, request)
proc, err := sandbox.ExecuteBackgroundContext(ctx, command, cwd, readPaths, writePaths)
if err != nil {
return mcp.NewToolResultError(opts.errorText(err)), nil
}
if asTask {
return awaitBackgroundTask(ctx, sandbox, proc)
}
msg := fmt.Sprintf(
"Started background process with shell id %q.\nIts output is being written to %s; read it with this tool (e.g. `tail -n 50 %s`). Use the list_shells tool to see whether it has finished and its exit code, and the kill_shell tool (shell_id=%q) to stop it.",
proc.ID, proc.OutputPath, proc.OutputPath, proc.ID,
Expand Down Expand Up @@ -190,6 +258,39 @@ func newMCPServer(sandbox *bash_sandboxed.Sandbox, opts serveOptions) *server.MC
return s
}

// awaitBackgroundTask is the body of a background command run as an MCP task:
// it waits for proc to exit and returns what the client hands the agent when
// the task ends — the exit status, the output file, and the end of the output.
// The task's context ends when the client cancels the task (tasks/cancel),
// which stops the command.
func awaitBackgroundTask(ctx context.Context, sandbox *bash_sandboxed.Sandbox, proc *bash_sandboxed.BackgroundProcess) (*mcp.CallToolResult, error) {
select {
case <-proc.Done():
case <-ctx.Done():
// Already exited is fine: the task is cancelled either way.
_ = sandbox.KillBackground(proc.ID)
<-proc.Done()
return nil, ctx.Err()
}
st := proc.Summary()
var b strings.Builder
switch st.Status {
case "killed":
fmt.Fprintf(&b, "Background command %s was killed.\n", st.ID)
default:
fmt.Fprintf(&b, "Background command %s exited with code %d.\n", st.ID, st.ExitCode)
}
fmt.Fprintf(&b, "Command: %s\nOutput file: %s\n", st.Command, st.OutputPath)
if tail := proc.OutputTail(taskOutputTailBytes); tail != "" {
fmt.Fprintf(&b, "End of output:\n%s", tail)
} else {
b.WriteString("It produced no output.")
}
result := mcp.NewToolResultText(strings.TrimRight(b.String(), "\n"))
result.IsError = st.Status != "completed" || st.ExitCode != 0
return result, nil
}

// approveCall returns ctx marked approved (bash_sandboxed.WithApproval) when
// the user approved this bash tool call in the agent's permission prompt: the
// PreToolUse hook asked about it, because it runs a command whose commands
Expand Down
218 changes: 218 additions & 0 deletions cmd/serve_tasks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package cmd

import (
"encoding/json"
"fmt"
"slices"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/mark3labs/mcp-go/server"

bash_sandboxed "github.com/gartnera/lite-sandbox/tool/bash_sandboxed"
)

// taskTestServer is a server driven over raw JSON-RPC on protocol revision
// 2026-07-28, so each request carries its own client capabilities, as Claude
// Code sends them.
type taskTestServer struct {
t *testing.T
sandbox *bash_sandboxed.Sandbox
srv *server.MCPServer
nextID atomic.Int64
}

func newTaskTestServer(t *testing.T) *taskTestServer {
t.Helper()
sb := bash_sandboxed.NewSandbox()
t.Cleanup(func() { sb.Close() })
return &taskTestServer{t: t, sandbox: sb, srv: newMCPServer(sb, serveOptions{})}
}

// call sends method with params, declaring the tasks extension when tasks is
// set, and returns the JSON-RPC result, failing the test on an error reply.
func (s *taskTestServer) call(method string, params map[string]any, tasks bool) map[string]any {
s.t.Helper()
caps := map[string]any{}
if tasks {
caps["extensions"] = map[string]any{"io.modelcontextprotocol/tasks": map[string]any{}}
}
if params == nil {
params = map[string]any{}
}
params["_meta"] = map[string]any{
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": map[string]any{"name": "test-client", "version": "0.0.1"},
"io.modelcontextprotocol/clientCapabilities": caps,
}
raw, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": s.nextID.Add(1), "method": method, "params": params})
if err != nil {
s.t.Fatal(err)
}
reply, err := json.Marshal(s.srv.HandleMessage(s.t.Context(), raw))
if err != nil {
s.t.Fatal(err)
}
var msg struct {
Result map[string]any `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal(reply, &msg); err != nil {
s.t.Fatal(err)
}
if msg.Error != nil {
s.t.Fatalf("%s returned an error: %s", method, reply)
}
return msg.Result
}

func (s *taskTestServer) bash(command string, background, tasks bool) map[string]any {
s.t.Helper()
return s.call("tools/call", map[string]any{
"name": "bash",
"arguments": map[string]any{"command": command, "run_in_background": background},
}, tasks)
}

// awaitTask polls tasks/get until the task leaves "working", and returns the
// final reply.
func (s *taskTestServer) awaitTask(taskID string) map[string]any {
s.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
got := s.call("tasks/get", map[string]any{"taskId": taskID}, true)
if got["status"] != "working" {
return got
}
time.Sleep(20 * time.Millisecond)
}
s.t.Fatalf("task %s still working after 10s", taskID)
return nil
}

// resultText returns the text of a tools/call result and whether it is an
// error.
func resultText(t *testing.T, result map[string]any) (string, bool) {
t.Helper()
content, _ := result["content"].([]any)
if len(content) == 0 {
t.Fatalf("result has no content: %v", result)
}
first, _ := content[0].(map[string]any)
isErr, _ := result["isError"].(bool)
return fmt.Sprint(first["text"]), isErr
}

func toolNames(result map[string]any) []string {
var names []string
tools, _ := result["tools"].([]any)
for _, tool := range tools {
m, _ := tool.(map[string]any)
names = append(names, fmt.Sprint(m["name"]))
}
slices.Sort(names)
return names
}

// TestTasks_ControlToolsHidden checks that a client running background
// commands as tasks is offered only bash, and cannot call the control tools,
// while any other client keeps them.
func TestTasks_ControlToolsHidden(t *testing.T) {
s := newTaskTestServer(t)
if got := toolNames(s.call("tools/list", nil, true)); !slices.Equal(got, []string{"bash"}) {
t.Errorf("tasks client: tools = %v, want only bash", got)
}
if got := toolNames(s.call("tools/list", nil, false)); !slices.Equal(got, []string{"bash", "kill_shell", "list_shells"}) {
t.Errorf("plain client: tools = %v, want bash, kill_shell, list_shells", got)
}

raw := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_shells","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"extensions":{"io.modelcontextprotocol/tasks":{}}}}}}`
reply, _ := json.Marshal(s.srv.HandleMessage(t.Context(), []byte(raw)))
if !strings.Contains(string(reply), `"error"`) {
t.Errorf("tasks client could call list_shells: %s", reply)
}
}

// TestTasks_BackgroundRunsAsTask checks that a background command from a
// tasks client is answered with a task whose result, once the command exits,
// carries its exit code, output file, and output.
func TestTasks_BackgroundRunsAsTask(t *testing.T) {
s := newTaskTestServer(t)
created := s.bash("echo task-hello; exit 3", true, true)
if created["resultType"] != "task" || created["status"] != "working" {
t.Fatalf("expected a working task handle, got %v", created)
}
taskID := fmt.Sprint(created["taskId"])

final := s.awaitTask(taskID)
if final["status"] != "completed" {
t.Fatalf("task status = %v, want completed: %v", final["status"], final)
}
result, _ := final["result"].(map[string]any)
text, isErr := resultText(t, result)
for _, want := range []string{"exited with code 3", "task-hello", "Output file: "} {
if !strings.Contains(text, want) {
t.Errorf("task result missing %q: %q", want, text)
}
}
if !isErr {
t.Errorf("a non-zero exit should be reported as an error result: %q", text)
}

procs := s.sandbox.ListBackground()
if len(procs) != 1 || procs[0].Status != "failed" || procs[0].ExitCode != 3 {
t.Errorf("expected the task's process to be tracked as failed with exit 3, got %+v", procs)
}
}

// TestTasks_ForegroundStaysSynchronous checks that only background commands
// become tasks: a plain bash call from a tasks client is answered directly.
func TestTasks_ForegroundStaysSynchronous(t *testing.T) {
s := newTaskTestServer(t)
result := s.bash("echo direct", false, true)
if result["resultType"] == "task" {
t.Fatalf("foreground call became a task: %v", result)
}
if text, isErr := resultText(t, result); isErr || text != "direct\n" {
t.Fatalf("got %q (error %v), want direct output", text, isErr)
}
}

// TestTasks_PlainClientGetsShellID checks that a client without the tasks
// extension keeps the shell id reply for background commands.
func TestTasks_PlainClientGetsShellID(t *testing.T) {
s := newTaskTestServer(t)
result := s.bash("echo plain", true, false)
if result["resultType"] == "task" {
t.Fatalf("background call from a plain client became a task: %v", result)
}
if text, _ := resultText(t, result); !strings.Contains(text, "shell id") {
t.Fatalf("expected a shell id reply, got %q", text)
}
}

// TestTasks_CancelKillsCommand checks that cancelling the task stops the
// background command.
func TestTasks_CancelKillsCommand(t *testing.T) {
s := newTaskTestServer(t)
created := s.bash("sleep 30", true, true)
taskID := fmt.Sprint(created["taskId"])

s.call("tasks/cancel", map[string]any{"taskId": taskID}, true)
final := s.awaitTask(taskID)
if final["status"] != "cancelled" {
t.Errorf("task status = %v, want cancelled: %v", final["status"], final)
}

deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
procs := s.sandbox.ListBackground()
if len(procs) == 1 && procs[0].Status == "killed" {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("background command not killed after cancel: %+v", s.sandbox.ListBackground())
}
26 changes: 26 additions & 0 deletions docs/background-processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ the file the command's output is written to. The tools involved:
output file.
- **`kill_shell`** (`shell_id`): stops a running background process.

**MCP tasks.** A client that declares the MCP tasks extension
(`io.modelcontextprotocol/tasks`, on protocol revision 2026-07-28) gets
background commands as tasks instead. The `bash` call returns a task handle,
the client polls it with `tasks/get`, and when the command exits the task
completes with its exit code, its output file, and the last 8 KiB of its
output. A non-zero exit or a kill is reported as an error result. Cancelling
the task (`tasks/cancel`) kills the command. Such a client follows and stops
background commands through the task, so `list_shells` and `kill_shell` are
neither listed for it nor callable by it. Foreground commands are still
answered directly.

In Claude Code (2.1.304 or later), these commands show in `/tasks`, can be
stopped from there, and wake Claude with the result when they finish, the way
its own background `Bash` commands do. Claude Code only uses MCP tasks in some
sessions (the interactive CLI, and Agent SDK sessions with
`CLAUDE_AUTO_BACKGROUND_TASKS=1`, among other conditions; see its
[MCP docs](https://code.claude.com/docs/en/mcp#mcp-tasks-that-run-on-the-server)).
It never uses them for calls from subagents or one-shot `claude -p` runs.
Those calls, and every other agent's, get the shell id reply. A subagent shares
its session's tool list, though, so in a session that runs tasks it is not
offered `list_shells` or `kill_shell`. It can still read its background
command's output file, and the command is stopped when the session ends.
Because a command is validated once its task exists, a command the sandbox
refuses shows up as a task that fails on its first poll rather than as an
immediate error.

**Reading output.** There is no dedicated output tool: stdout and stderr are
written, as they are produced, to `<shell id>.log` in a per-server directory
under `$TMPDIR/lite-sandbox-<uid>/` (`/tmp/lite-sandbox-<uid>/` on Linux), and
Expand Down
2 changes: 2 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ Add this to `~/.claude/settings.json` so Claude Code never prompts for the sandb

The `kill_shell` and `list_shells` entries cover the background-process
tools, so checking on and stopping background commands don't prompt either.
Sessions that run background commands as MCP tasks don't get those two tools
at all (see [Background processes](background-processes.md)).

Without the `Bash` deny, Claude could fall back to the unvalidated built-in shell whenever the sandbox rejected a command.

Expand Down
Loading
Loading