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:
parent
5f8c41b576
commit
8a55b99864
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Reference in New Issue