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
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,17 +222,29 @@ lea symbols auth -k interface # Filter by kind
lea symbols -p internal # Filter by package
```

### 3. Start the MCP Server
### 3. Universal MCP Install (Recommended)

Connect your favorite AI agent (Claude Code, Aider, Pi, etc.) directly to your codebase:
Register `lea` and `lynx` as MCP tools across all supported AI agents in one command:

```bash
lea mcp install
```

This auto-detects installed tools (Claude Code, VS Code Cline/Roo Code/Codex CLI, OpenCode, Pi, Zed, Gemini CLI, OpenClaw, Aider, Antigravity, Kiro, KiloCode) and injects the `pizen-lea` and `pizen-lynx` MCP entries into their config files — JSON, YAML, or TOML as appropriate.

Also generates `~/.config/pizen/instructions.md` with the dual-tool orchestration protocol.

### 4. Start the MCP Server

Connect your favorite AI agent directly to your codebase:

```bash
lea mcp
```

Exposes MCP tools: `get_symbol_context`, `find_neighbors`, `trace_calls`, `trace_execution_path`, `find_architecture_violations`.

### 4. Export agent configurations
### 5. Export agent configurations

Generate bootstrap rule pointers for AI agent ecosystems:

Expand All @@ -245,15 +257,15 @@ lea export copilot # Creates .github/copilot-instructions.md
lea export pi # Creates .pi/AGENTS.md
```

### 5. Interactive Exploration
### 6. Interactive Exploration

Launch the TUI for fuzzy symbol search and dependency browsing:

```bash
lea tui
```

### 6. Analyze impact and context
### 7. Analyze impact and context

```bash
# Blast radius analysis
Expand All @@ -272,7 +284,7 @@ lea flow "func:internal/cli/commands:Execute"
lea neighbors AuthService
```

### 7. Watch for changes
### 8. Watch for changes

Real-time incremental indexing without full re-index:

Expand All @@ -290,6 +302,7 @@ lea watch .
| `symbols` | Discover and list symbols in the registry | `lea symbols auth -k interface` |
| `tui` | Open the interactive symbol explorer (Bubble Tea) | `lea tui` |
| `mcp` | Start the Model Context Protocol server (stdio) | `lea mcp` |
| `mcp install` | One-command MCP setup for lea & lynx across 11 AI tools | `lea mcp install` |
| `export` | Generate AI agent configuration bootstrap pointers | `lea export claude` |
| `context` | Generate budget-aware context for a symbol | `lea context AuthService --budget 2000` |
| `trace` | Follow the recursive call graph from a function | `lea trace "func:internal/cli:Execute"` |
Expand Down Expand Up @@ -479,6 +492,8 @@ sequenceDiagram
- **Missing symbols?** Confirm the target language parser is supported (Go, Python, Rust, TypeScript). Non-Go languages use Tree-sitter — file a GitHub issue for unsupported languages.
- **Architecture checks fail?** Ensure your rules file (e.g., `arch.yaml`) is present and valid YAML. Check layer patterns match your directory structure.
- **MCP not connecting?** Verify the MCP server is running (`lea mcp`) and your AI agent is configured to connect to it via stdio.
- **MCP install skips a tool?** That's expected — `lea mcp install` only injects entries into tools whose config directory already exists on your system, avoiding noise from uninstalled applications.
- **Missing agent on the install list?** Run `lea mcp install` — it currently supports 11 targets (Claude Code, VS Code Cline/Roo/Codex CLI, OpenCode, Pi, Zed, Gemini CLI, OpenClaw, Aider, Antigravity, Kiro, KiloCode).
- **TUI shows no symbols?** Ensure `lea index .` completed successfully — the TUI reads from `.lea/graph.db`.
- **Export file missing?** Check that you ran `lea export <target>` from the repository root. The tool creates the necessary subdirectories automatically.

Expand Down Expand Up @@ -542,7 +557,7 @@ lea/
│ │ ├── flow.go # lea flow
│ │ ├── impact.go # lea impact (blast radius)
│ │ ├── index.go # lea index (+metadata generation)
│ │ ├── mcp.go # lea mcp
│ │ ├── mcp.go # lea mcp (server + install)
│ │ ├── neighbors.go # lea neighbors
│ │ ├── root.go # Root command + symbol resolution
│ │ ├── symbols.go # lea symbols
Expand All @@ -554,7 +569,9 @@ lea/
│ ├── graph/contracts/ # Graph node/edge type definitions
│ │ ├── edge.go # 8 edge types
│ │ └── node.go # 7 node types
│ ├── mcp/server.go # MCP server (5 tools)
│ ├── mcp/
│ │ ├── install/install.go # MCP install (11 target writers)
│ │ └── server.go # MCP server (5 tools)
│ ├── parser/
│ │ ├── contracts/parser.go # Parser interface
│ │ ├── golang/parser.go # Go AST parser with deep resolution
Expand Down
37 changes: 34 additions & 3 deletions internal/cli/commands/mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"os"
"path/filepath"

"github.com/PizenLabs/lea/internal/mcp"
Expand All @@ -9,12 +10,42 @@ import (
"github.com/spf13/cobra"
)

// findLeaDir walks up from dir looking for a .lea directory.
func findLeaDir(dir string) (string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return "", err
}
for {
candidate := filepath.Join(abs, ".lea")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate, nil
}
parent := filepath.Dir(abs)
if parent == abs {
return "", os.ErrNotExist
}
abs = parent
}
}

var mcpCmd = &cobra.Command{
Use: "mcp",
Use: "mcp [path]",
Short: "Start the MCP server to expose lea to AI agents",
Long: `The mcp command starts a Model Context Protocol server over stdio, allowing AI agents like Claude or Pi to query the structural graph.`,
RunE: func(_ *cobra.Command, _ []string) error {
dbPath := filepath.Join(".lea", "graph.db")
Args: cobra.MaximumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
dir := "."
if len(args) > 0 {
dir = args[0]
}

leaDir, err := findLeaDir(dir)
if err != nil {
return err
}

dbPath := filepath.Join(leaDir, "graph.db")
store, err := sqlite.NewStore(dbPath)
if err != nil {
return err
Expand Down
95 changes: 65 additions & 30 deletions internal/mcp/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import (

// MCPEntry represents a single MCP tool entry in the JSON config schema.
type MCPEntry struct {
Command string `json:"command" yaml:"cmd" toml:"command"`
Args []string `json:"args" yaml:"-" toml:"args"`
Command string `json:"command" yaml:"cmd" toml:"command"`
Args []string `json:"args" yaml:"-" toml:"args"`
Env map[string]string `json:"env,omitempty" yaml:"-" toml:"-"`
}

type target struct {
Expand All @@ -33,15 +34,14 @@ func installTargets() []target {
home := homeDir()
configDir := filepath.Join(home, ".config")

// VS Code / OpenCode base path is platform-dependent
vscodeBase := vscodeGlobalStorageDir(home)
opencodeBase := opencodeGlobalStorageDir(home)

return []target{
{Name: "Claude Code", Path: filepath.Join(home, ".claude", ".mcp.json"), Format: "json"},
{Name: "VS Code (Cline/Roo Code/Codex CLI)", Path: filepath.Join(vscodeBase, "saoudrizwan.claude-dev", "settings", "mcp_settings.json"), Format: "json"},
{Name: "OpenCode", Path: filepath.Join(opencodeBase, "saoudrizwan.claude-dev", "settings", "mcp_settings.json"), Format: "json"},
{Name: "Pi Coding Agents", Path: filepath.Join(home, ".pi", "agent", "mcp_config.json"), Format: "json"},
{Name: "OpenCode", Path: filepath.Join(configDir, "opencode", "opencode.json"), Format: "opencode"},
{Name: "Pi Coding Agents", Path: filepath.Join(home, ".pi", "agent", "mcp.json"), Format: "json"},
{Name: "PizenLabs Shared MCP", Path: filepath.Join(configDir, "mcp", "mcp.json"), Format: "json"},
{Name: "Zed IDE", Path: filepath.Join(home, ".zed", "settings.json"), Format: "json"},
{Name: "Gemini CLI", Path: filepath.Join(configDir, "gemini-cli", "mcp.json"), Format: "json"},
{Name: "OpenClaw", Path: filepath.Join(configDir, "openclaw", "mcp.json"), Format: "json"},
Expand Down Expand Up @@ -79,25 +79,6 @@ func vscodeGlobalStorageDir(home string) string {
}
}

// opencodeGlobalStorageDir returns the OpenCode globalStorage path for the current OS.
func opencodeGlobalStorageDir(home string) string {
// OpenCode uses the same directory structure as VS Code under its own config root.
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "OpenCode", "User", "globalStorage")
case "linux":
return filepath.Join(home, ".config", "OpenCode", "User", "globalStorage")
case "windows":
appData := os.Getenv("APPDATA")
if appData == "" {
appData = filepath.Join(home, "AppData", "Roaming")
}
return filepath.Join(appData, "OpenCode", "User", "globalStorage")
default:
return filepath.Join(home, ".config", "OpenCode", "User", "globalStorage")
}
}

// Run configures all MCP targets with pizen-lea and pizen-lynx entries.
func Run() error {
// Resolve lea binary path
Expand Down Expand Up @@ -154,12 +135,21 @@ func resolveLXFallback() string {
func configureTarget(t target, leaPath, lxPath string) error {
parent := filepath.Dir(t.Path)
if _, err := os.Stat(parent); os.IsNotExist(err) {
return fmt.Errorf("parent directory %q does not exist", parent)
// Create parent directory for PizenLabs Shared MCP and other writable targets
if t.Name == "PizenLabs Shared MCP" {
if err := os.MkdirAll(parent, 0755); err != nil {
return fmt.Errorf("cannot create parent directory %q: %w", parent, err)
}
} else {
return fmt.Errorf("parent directory %q does not exist", parent)
}
}

switch t.Format {
case "json":
return injectJSON(t.Path, leaPath, lxPath)
case "opencode":
return injectOpenCodeJSON(t.Path, leaPath, lxPath)
case "yaml":
return injectYAML(t.Path, leaPath, lxPath)
case "toml":
Expand Down Expand Up @@ -192,8 +182,14 @@ func injectJSON(path, leaPath, lxPath string) error {
if !ok || servers == nil {
servers = make(map[string]any)
}
servers["pizen-lea"] = MCPEntry{Command: leaPath, Args: []string{"mcp"}}
servers["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}}
env := map[string]string{
"PATH": os.Getenv("PATH"),
"HOME": os.Getenv("HOME"),
}
leaEntry := MCPEntry{Command: leaPath, Args: []string{"mcp"}, Env: env}
lxEntry := MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env}
servers["pizen-lea"] = leaEntry
servers["pizen-lynx"] = lxEntry
raw["mcpServers"] = servers

return writeJSON(path, raw)
Expand All @@ -206,9 +202,48 @@ func injectZedJSON(raw map[string]any, path, leaPath, lxPath string) error {
mcp = make(map[string]any)
}

env := map[string]string{
"PATH": os.Getenv("PATH"),
"HOME": os.Getenv("HOME"),
}

// Zed format: "pizen-lea": { "command": "...", "args": ["mcp"] }
mcp["pizen-lea"] = MCPEntry{Command: leaPath, Args: []string{"mcp"}}
mcp["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}}
mcp["pizen-lea"] = MCPEntry{Command: leaPath, Args: []string{"mcp"}, Env: env}
mcp["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env}
raw["mcp"] = mcp

return writeJSON(path, raw)
}

// injectOpenCodeJSON handles OpenCode's MCP config format under root "mcp" key.
// OpenCode uses: { "enabled": true, "type": "local", "command": ["path", "mcp"] }
func injectOpenCodeJSON(path, leaPath, lxPath string) error {
data := readOrEmpty(path)

var raw map[string]any
if len(data) > 0 {
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("unmarshal error: %w", err)
}
} else {
raw = make(map[string]any)
}

mcp, ok := raw["mcp"].(map[string]any)
if !ok || mcp == nil {
mcp = make(map[string]any)
}

mcp["pizen-lea"] = map[string]any{
"enabled": true,
"type": "local",
"command": []any{leaPath, "mcp"},
}
mcp["pizen-lynx"] = map[string]any{
"enabled": true,
"type": "local",
"command": []any{lxPath, "mcp"},
}
raw["mcp"] = mcp

return writeJSON(path, raw)
Expand Down
10 changes: 8 additions & 2 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,18 @@ func (s *Server) Start() error {
mcp_golang.WithName("lea"),
)

// Register all tools.
if err := s.registerTools(server); err != nil {
return err
}

return server.Serve()
if err := server.Serve(); err != nil {
return err
}

// mcp-golang v0.16.1 Serve() is non-blocking — the read-loop goroutine
// has already started. Block forever so the process stays alive until
// opencode kills the subprocess.
select {}
}

// registerTools registers every MCP tool exposed by lea.
Expand Down
Loading