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
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,58 @@ Implementation notes:
- Exit codes: 0=success, 1=recoverable error, 2=panic (recoverable), 3=fatal error
- Non-streaming printer renders generated code as syntax-highlighted Go blocks

## MCP Server Mode

MCP Server Mode exposes CPE as an MCP server, enabling subagent composition within other MCP clients. Each subagent is defined in its own config file and exposed as a single tool.

**Running a subagent server:**

```bash
./cpe mcp serve --config ./subagent.cpe.yaml
```

**Subagent configuration:**

```yaml
version: "1.0"

models:
- ref: opus
id: claude-opus-4-20250514
type: anthropic
api_key_env: ANTHROPIC_API_KEY

subagent:
name: task_name # Tool name exposed to parent
description: "..." # Tool description
outputSchemaPath: ./out.json # Optional structured output

defaults:
model: opus
systemPromptPath: ./prompt.md
codeMode:
enabled: true # Subagent can use code mode
```

**Using from parent config:**

```yaml
mcpServers:
my_subagent:
command: cpe
args: ["mcp", "serve", "--config", "./subagent.cpe.yaml"]
type: stdio
```

Implementation notes:

- Server uses stdio transport only; stdout is reserved for MCP protocol
- Subagent inherits CWD and environment from server process
- If `outputSchemaPath` is set, a `final_answer` tool extracts structured output
- Execution traces are saved to `.cpeconvo` with `subagent:<name>:<run_id>` labels
- No retries on failure; errors propagate directly to caller
- Key files: `cmd/mcp.go`, `internal/mcp/server.go`, `internal/commands/subagent.go`

## Documentation for Go Symbols

When gathering context about symbols like types, global variables, constants, functions and methods, prefer to use
Expand Down
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,80 @@ Generated code runs with the same permissions as the CPE process. For production
- Setting conservative execution timeouts
- Carefully configuring which tools are exposed

### MCP Server Mode

MCP Server Mode allows CPE to be exposed as an MCP server, enabling composition of AI agents as tools within other MCP-compliant environments. This is useful for:

- **Context management**: Subagents work with shorter context windows, improving quality
- **Parallel execution**: Multiple subagents can run concurrently via code mode
- **Task specialization**: Different subagents for different workflows (testing, reviewing, etc.)

#### Creating a Subagent

Create a dedicated config file for your subagent:

```yaml
# review_agent.cpe.yaml
version: "1.0"

models:
- ref: opus
display_name: "Claude Opus"
id: claude-opus-4-20250514
type: anthropic
api_key_env: ANTHROPIC_API_KEY

subagent:
name: review_changes
description: Review a diff and return prioritized feedback.
outputSchemaPath: ./schemas/review.json # optional, for structured output

defaults:
model: opus
systemPromptPath: ./prompts/review.prompt
```

#### Running the Server

```bash
cpe mcp serve --config ./review_agent.cpe.yaml
```

#### Using from a Parent Agent

Configure the subagent as an MCP server in your parent config:

```yaml
# parent.cpe.yaml
mcpServers:
reviewer:
command: cpe
args: ["mcp", "serve", "--config", "./review_agent.cpe.yaml"]
type: stdio
```

The parent can then invoke the `review_changes` tool with a prompt:

```json
{
"prompt": "Review this diff for potential issues",
"inputs": ["changes.diff"]
}
```

#### Subagent Configuration

| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Tool name exposed to the parent agent |
| `description` | Yes | Tool description shown to the parent agent |
| `outputSchemaPath` | No | Path to JSON schema for structured output |

Subagents inherit the server's CWD and environment. They can use code mode and MCP servers defined in the config.

See [docs/specs/mcp_server_mode.md](docs/specs/mcp_server_mode.md) for the complete specification.


## Examples

### Code Creation
Expand Down
201 changes: 201 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"

"github.com/charmbracelet/glamour"
"github.com/google/jsonschema-go/jsonschema"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/spachava753/cpe/internal/agent"
"github.com/spachava753/cpe/internal/commands"
"github.com/spachava753/cpe/internal/config"
"github.com/spachava753/cpe/internal/mcp"
"github.com/spachava753/cpe/internal/storage"
"github.com/spachava753/gai"
"github.com/spf13/cobra"
)

const (
// runIDCharset is the character set for generating run IDs
runIDCharset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// runIDLength is the length of generated run IDs
runIDLength = 8
)

var (
mcpServerName string
mcpToolName string
Expand Down Expand Up @@ -151,6 +166,191 @@ var mcpCallToolCmd = &cobra.Command{
},
}

// mcpServeCmd represents the 'mcp serve' subcommand
var mcpServeCmd = &cobra.Command{
Use: "serve",
Short: "Run CPE as an MCP server",
Long: `Start CPE as an MCP server that exposes a configured subagent as a tool.

The server communicates via stdio and exposes exactly one tool based on
the subagent configuration in the provided config file.

This command requires an explicit --config flag pointing to a subagent
configuration file. The default config search behavior is disabled.`,
Example: ` # Start the MCP server with a subagent config
cpe mcp serve --config ./coder_agent.cpe.yaml`,
RunE: func(cmd *cobra.Command, args []string) error {
// For mcp serve, we require an explicit config path - don't use default search
configFlag := cmd.Root().PersistentFlags().Lookup("config")
if configFlag == nil || !configFlag.Changed {
return fmt.Errorf("--config flag is required for mcp serve")
}

// Load raw config to check subagent is configured
rawCfg, err := config.LoadRawConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

// Validate that subagent is configured
if rawCfg.Subagent == nil {
return fmt.Errorf("config must define a subagent for MCP server mode")
}

// Load and validate output schema at startup
var outputSchema *jsonschema.Schema
if rawCfg.Subagent.OutputSchemaPath != "" {
schemaBytes, err := os.ReadFile(rawCfg.Subagent.OutputSchemaPath)
if err != nil {
return fmt.Errorf("failed to read output schema file %q: %w", rawCfg.Subagent.OutputSchemaPath, err)
}
if err := json.Unmarshal(schemaBytes, &outputSchema); err != nil {
return fmt.Errorf("invalid output schema JSON in %q: %w", rawCfg.Subagent.OutputSchemaPath, err)
}
}

// Initialize storage for persisting execution traces
dialogStorage, err := storage.InitDialogStorage(".cpeconvo")
if err != nil {
return fmt.Errorf("failed to initialize dialog storage: %w", err)
}
defer dialogStorage.Close()

// Create the executor with pre-loaded schema and storage
executor := createSubagentExecutor(configPath, outputSchema, rawCfg.Subagent.Name, dialogStorage)

// Create server config
serverCfg := mcp.MCPServerConfig{
Subagent: mcp.SubagentDef{
Name: rawCfg.Subagent.Name,
Description: rawCfg.Subagent.Description,
OutputSchemaPath: rawCfg.Subagent.OutputSchemaPath,
},
MCPServers: rawCfg.MCPServers,
}

// Create and run the MCP server
server, err := mcp.NewServer(serverCfg, mcp.ServerOptions{
Executor: executor,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}

return server.Serve(cmd.Context())
},
}

// generateRunID generates a unique run ID for subagent invocations
func generateRunID() string {
id, err := gonanoid.Generate(runIDCharset, runIDLength)
if err != nil {
// Fallback to timestamp-based ID if nanoid fails
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return id
}

// createSubagentExecutor creates an executor function that runs the subagent.
// The outputSchema is pre-loaded at startup and passed to each execution.
// Storage and subagent name are used to persist execution traces.
func createSubagentExecutor(cfgPath string, outputSchema *jsonschema.Schema, subagentName string, dialogStorage commands.DialogStorage) mcp.SubagentExecutor {
return func(ctx context.Context, input mcp.SubagentInput) (string, error) {
// Check context before starting
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("execution cancelled before start: %w", err)
}

// Generate unique run ID for this invocation
runID := generateRunID()
subagentLabel := fmt.Sprintf("subagent:%s:%s", subagentName, runID)

// Resolve effective config (uses defaults.model from config)
effectiveConfig, err := config.ResolveConfig(cfgPath, config.RuntimeOptions{})
if err != nil {
return "", fmt.Errorf("failed to resolve config %q: %w", cfgPath, err)
}

// Build user blocks from prompt and input files
userBlocks, err := agent.BuildUserBlocks(ctx, input.Prompt, input.Inputs)
if err != nil {
// Provide actionable error for input file issues
if len(input.Inputs) > 0 {
return "", fmt.Errorf("failed to read input files %v: %w", input.Inputs, err)
}
return "", fmt.Errorf("failed to build user input: %w", err)
}

// Load and render system prompt (same pattern as root.go)
var systemPrompt string
if effectiveConfig.SystemPromptPath != "" {
f, err := os.Open(effectiveConfig.SystemPromptPath)
if err != nil {
return "", fmt.Errorf("could not open system prompt file %q: %w", effectiveConfig.SystemPromptPath, err)
}
defer f.Close()

contents, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("failed to read system prompt file %q: %w", effectiveConfig.SystemPromptPath, err)
}

systemPrompt, err = agent.SystemPromptTemplate(string(contents), agent.TemplateData{
Config: effectiveConfig,
})
if err != nil {
return "", fmt.Errorf("failed to prepare system prompt: %w", err)
}
}

// Check context before creating generator (can involve network calls)
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("execution cancelled during setup: %w", err)
}

// Create the generator
generator, err := agent.CreateToolCapableGenerator(
ctx,
effectiveConfig.Model,
systemPrompt,
effectiveConfig.Timeout,
effectiveConfig.NoStream,
true, // disablePrinting - MCP server mode must not write to stdout
effectiveConfig.MCPServers,
effectiveConfig.CodeMode,
)
if err != nil {
return "", fmt.Errorf("failed to create generator for model %q: %w", effectiveConfig.Model.Ref, err)
}

// Build generation options function
var genOptsFunc gai.GenOptsGenerator
if effectiveConfig.GenerationDefaults != nil {
genOptsFunc = func(_ gai.Dialog) *gai.GenOpts {
return effectiveConfig.GenerationDefaults
}
}

// Execute the subagent with storage
result, err := commands.ExecuteSubagent(ctx, commands.SubagentOptions{
UserBlocks: userBlocks,
Generator: generator,
GenOptsFunc: genOptsFunc,
OutputSchema: outputSchema,
Storage: dialogStorage,
SubagentLabel: subagentLabel,
})
if err != nil {
// Annotate context cancellation errors
if ctx.Err() != nil {
return "", fmt.Errorf("subagent %q execution timed out or cancelled: %w", subagentName, err)
}
return "", fmt.Errorf("subagent %q execution failed: %w", subagentName, err)
}
return result, nil
}
}

func init() {
rootCmd.AddCommand(mcpCmd)

Expand All @@ -159,6 +359,7 @@ func init() {
mcpCmd.AddCommand(mcpInfoCmd)
mcpCmd.AddCommand(mcpListToolsCmd)
mcpCmd.AddCommand(mcpCallToolCmd)
mcpCmd.AddCommand(mcpServeCmd)

// Add flags to mcp list-tools command
mcpListToolsCmd.Flags().Bool("show-all", false, "Show all tools including filtered ones")
Expand Down
Loading