p9: capability-based tools discovery

- Add front-matter parsing for capabilities/description in tool scripts
- Virtual capability directories: tools appear in multiple caps
- /tools/help aggregated one-liner index
- Update walk/readDir for /tools/<capability>/<tool.sh> paths
This commit is contained in:
Levi Neely 2026-02-26 14:36:30 +01:00
parent 5f8c41b576
commit 8a55b99864
3 changed files with 282 additions and 55 deletions

View File

@ -450,17 +450,33 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
newPath = "/beads/" + name
}
} else if path == "/tools" {
// Inside tools directory
if name == "anvilmcp" {
qid = plan9.Qid{Type: QTDir, Path: qidToolsBase + 1}
newPath = "/tools/anvilmcp"
// Inside tools directory - list capabilities + help
if s.tools != nil {
caps, _ := s.tools.listCapabilities()
// help file
qid = plan9.Qid{Type: QTFile, Path: qidToolsBase}
if name == "help" {
newPath = "/tools/help"
} else {
// Check if it's a capability
for _, cap := range caps {
if cap == name {
qid = plan9.Qid{Type: QTDir, Path: qidToolsBase + hashID(name)}
newPath = "/tools/" + name
break
}
}
if newPath == "" {
return errFcall(fc, "not found")
}
}
} else {
return errFcall(fc, "not found")
}
} else if path == "/tools/anvilmcp" {
// Inside tools/anvilmcp - tool files
qid = plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(name)}
newPath = "/tools/anvilmcp/" + name
} else if strings.HasPrefix(path, "/tools/") && strings.Count(path, "/") == 2 {
// Inside tools/<capability> - tool files
qid = plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(path+name)}
newPath = path + "/" + name
} else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 {
// Inside a bead directory - property files
beadID := strings.TrimPrefix(path, "/beads/")
@ -1024,17 +1040,30 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
})
}
} else if path == "/tools" {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTDir, Path: qidToolsBase},
Mode: plan9.DMDIR | 0555, Name: "anvilmcp", Uid: "q", Gid: "q", Muid: "q",
})
} else if path == "/tools/anvilmcp" {
if s.tools != nil {
toolDirs, _ := s.tools.List("agent/tools/anvilmcp")
for _, td := range toolDirs {
// Add help file
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase},
Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q",
})
// Add capability directories
caps, _ := s.tools.listCapabilities()
for _, cap := range caps {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(td.Name)},
Mode: 0444, Name: td.Name, Uid: "q", Gid: "q", Muid: "q",
Qid: plan9.Qid{Type: QTDir, Path: qidToolsBase + hashID(cap)},
Mode: plan9.DMDIR | 0555, Name: cap, Uid: "q", Gid: "q", Muid: "q",
})
}
}
} else if strings.HasPrefix(path, "/tools/") && strings.Count(path, "/") == 2 {
// Inside tools/<capability> - list tools
capability := strings.TrimPrefix(path, "/tools/")
if s.tools != nil {
tools, _ := s.tools.listToolsInCapability(capability)
for _, tool := range tools {
dirs = append(dirs, plan9.Dir{
Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(path+tool.Name)},
Mode: 0444, Name: tool.Name, Uid: "q", Gid: "q", Muid: "q",
})
}
}

View File

@ -1,15 +1,18 @@
package p9
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"9fans.net/go/plan9"
)
type ToolsFS struct {
tools []Tool
tools []Tool
toolsDir string
}
type Tool struct {
@ -35,77 +38,229 @@ type Items struct {
Type string `json:"type"`
}
// ToolMeta holds parsed front-matter from a tool script
type ToolMeta struct {
Name string
Capabilities []string
Description string
Path string
}
func NewToolsFS(tools []Tool) *ToolsFS {
return &ToolsFS{tools: tools}
homeDir, _ := os.UserHomeDir()
return &ToolsFS{
tools: tools,
toolsDir: filepath.Join(homeDir, ".config/anvillm/mcptools"),
}
}
// parseFrontMatter extracts capabilities and description from script comments
func parseFrontMatter(path string) (*ToolMeta, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
meta := &ToolMeta{
Name: filepath.Base(path),
Path: path,
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "#") {
break
}
line = strings.TrimPrefix(line, "#")
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "capabilities:") {
caps := strings.TrimPrefix(line, "capabilities:")
for _, c := range strings.Split(caps, ",") {
c = strings.TrimSpace(c)
if c != "" {
meta.Capabilities = append(meta.Capabilities, c)
}
}
} else if strings.HasPrefix(line, "description:") {
meta.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
}
}
return meta, nil
}
// listAllTools scans toolsDir and returns metadata for all .sh files
func (t *ToolsFS) listAllTools() ([]*ToolMeta, error) {
entries, err := os.ReadDir(t.toolsDir)
if err != nil {
return nil, err
}
var tools []*ToolMeta
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
continue
}
meta, err := parseFrontMatter(filepath.Join(t.toolsDir, entry.Name()))
if err != nil {
continue
}
tools = append(tools, meta)
}
return tools, nil
}
// listCapabilities returns unique capability names from all tools
func (t *ToolsFS) listCapabilities() ([]string, error) {
tools, err := t.listAllTools()
if err != nil {
return nil, err
}
capSet := make(map[string]bool)
for _, tool := range tools {
for _, cap := range tool.Capabilities {
capSet[cap] = true
}
}
var caps []string
for cap := range capSet {
caps = append(caps, cap)
}
return caps, nil
}
// listToolsInCapability returns tools that have the given capability
func (t *ToolsFS) listToolsInCapability(capability string) ([]*ToolMeta, error) {
tools, err := t.listAllTools()
if err != nil {
return nil, err
}
var result []*ToolMeta
for _, tool := range tools {
for _, cap := range tool.Capabilities {
if cap == capability {
result = append(result, tool)
break
}
}
}
return result, nil
}
// generateHelp creates aggregated one-liner index of all tools
func (t *ToolsFS) generateHelp() (string, error) {
tools, err := t.listAllTools()
if err != nil {
return "", err
}
var lines []string
for _, tool := range tools {
caps := strings.Join(tool.Capabilities, ",")
if caps == "" {
caps = "uncategorized"
}
line := fmt.Sprintf("%s/%s\t%s", caps, tool.Name, tool.Description)
lines = append(lines, line)
}
return strings.Join(lines, "\n") + "\n", nil
}
func (t *ToolsFS) List(path string) ([]plan9.Dir, error) {
parts := strings.Split(strings.Trim(path, "/"), "/")
// /tools - list capabilities + help file
if len(parts) == 2 && parts[0] == "agent" && parts[1] == "tools" {
return []plan9.Dir{
{Name: "anvilmcp", Qid: plan9.Qid{Type: plan9.QTDIR}, Mode: plan9.DMDIR | 0555},
}, nil
}
if len(parts) == 3 && parts[0] == "agent" && parts[1] == "tools" && parts[2] == "anvilmcp" {
// Dynamically list .sh files from ~/.config/anvillm/mcptools/
homeDir, err := os.UserHomeDir()
caps, err := t.listCapabilities()
if err != nil {
return nil, err
}
mcptoolsDir := fmt.Sprintf("%s/.config/anvillm/mcptools", homeDir)
entries, err := os.ReadDir(mcptoolsDir)
if err != nil {
return nil, err
}
var dirs []plan9.Dir
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
continue
}
// Add help file
dirs = append(dirs, plan9.Dir{
Name: "help",
Qid: plan9.Qid{Type: plan9.QTFILE},
Mode: 0444,
})
// Add capability directories
for _, cap := range caps {
dirs = append(dirs, plan9.Dir{
Name: entry.Name(),
Name: cap,
Qid: plan9.Qid{Type: plan9.QTDIR},
Mode: plan9.DMDIR | 0555,
})
}
return dirs, nil
}
// /tools/<capability> - list tools in that capability
if len(parts) == 3 && parts[0] == "agent" && parts[1] == "tools" {
capability := parts[2]
tools, err := t.listToolsInCapability(capability)
if err != nil {
return nil, err
}
var dirs []plan9.Dir
for _, tool := range tools {
dirs = append(dirs, plan9.Dir{
Name: tool.Name,
Qid: plan9.Qid{Type: plan9.QTFILE},
Mode: 0555,
})
}
return dirs, nil
}
return nil, fmt.Errorf("not found")
}
func (t *ToolsFS) Read(path string) ([]byte, error) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) != 3 || parts[0] != "tools" || parts[1] != "anvilmcp" {
// /tools/help - aggregated index
if len(parts) == 2 && parts[0] == "tools" && parts[1] == "help" {
help, err := t.generateHelp()
if err != nil {
return nil, err
}
return []byte(help), nil
}
// /tools/<capability>/<tool.sh> - read tool script
if len(parts) != 3 || parts[0] != "tools" {
return nil, fmt.Errorf("not found")
}
capability := parts[1]
filename := parts[2]
if !strings.HasSuffix(filename, ".sh") {
return nil, fmt.Errorf("not a shell script")
}
// Prevent path traversal
if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
return nil, fmt.Errorf("invalid filename")
}
// Read directly from disk
homeDir, err := os.UserHomeDir()
// Verify tool has this capability
tools, err := t.listToolsInCapability(capability)
if err != nil {
return nil, err
}
scriptPath := fmt.Sprintf("%s/.config/anvillm/mcptools/%s", homeDir, filename)
content, err := os.ReadFile(scriptPath)
if err != nil {
return nil, fmt.Errorf("script not found")
for _, tool := range tools {
if tool.Name == filename {
return os.ReadFile(tool.Path)
}
}
return content, nil
return nil, fmt.Errorf("tool not found in capability")
}

View File

@ -0,0 +1,43 @@
#!/bin/bash
# Update tool scripts with front-matter for capability-based discovery
set -euo pipefail
TOOLS_DIR="${HOME}/.config/anvillm/mcptools"
update_tool() {
local file="$1"
local caps="$2"
local desc="$3"
# Create temp file with new front-matter
{
echo "#!/bin/bash"
echo "# capabilities: $caps"
echo "# description: $desc"
# Skip old header lines, keep rest
tail -n +2 "$file" | grep -v "^# capabilities:" | grep -v "^# description:" | grep -v "^#.*Usage:" | grep -v "^# [a-z_]* -"
} > "${file}.tmp"
mv "${file}.tmp" "$file"
chmod +x "$file"
}
# send_message.sh
update_tool "$TOOLS_DIR/send_message.sh" "messaging" "Send message to agent or user: FROM TO TYPE SUBJECT BODY"
# read_inbox.sh
update_tool "$TOOLS_DIR/read_inbox.sh" "messaging" "Read agent inbox: AGENT_ID"
# list_sessions.sh
update_tool "$TOOLS_DIR/list_sessions.sh" "agents" "List all running agent sessions"
# set_state.sh
update_tool "$TOOLS_DIR/set_state.sh" "agents" "Set agent state: AGENT_ID STATE"
# list_skills.sh
update_tool "$TOOLS_DIR/list_skills.sh" "skills" "List available agent skills"
# test.sh
update_tool "$TOOLS_DIR/test.sh" "testing" "Test tool for debugging"
echo "Updated front-matter for all tools"