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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ Entries are stored in `<project>/.agents/<category>.yaml`:
- `conventions.yaml` — patterns, naming, build commands
- `subsystems.yaml` — how things work
- `decisions.yaml` — why choices were made
- `workflows.yaml` — procedures the agent runs on demand (brainstorm, plan, debug, verify)
- `_meta.yaml` — project metadata

Category files use a `project:`/`version:`/`entries:` wrapper — a bare YAML
Expand Down
2 changes: 2 additions & 0 deletions examples/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ Query knowledge for current context:
- `query_knowledge(project="<name>", category="conventions")` — patterns, build, naming
- `query_knowledge(project="<name>", query="<subsystem>")` — how it works
- `query_knowledge(project="<name>", category="decisions")` — past choices
- `query_knowledge(project="<name>", category="workflows")` — procedures to run

## After learning something significant

Write it back:
- `write_knowledge(project="<name>", category="subsystems", summary="...", detail="...", source="...")`
- `write_knowledge(project="<name>", category="decisions", summary="...", detail="...", source="...")`
- `write_knowledge(project="<name>", category="workflows", summary="...", detail="...", source="...")`
2 changes: 1 addition & 1 deletion install/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func RenderSkillMD(registry []tools.ToolSpec) []byte {
}
md := "---\n" +
"name: knowledge-mcp\n" +
"description: \"Use the persistent knowledge store for project conventions, subsystems, and decisions. Triggers on: what are our conventions, does this project have rules about, how does this subsystem work, why was this decision made, capture this decision, write that down as knowledge, update that entry, knowledge store, .agents, list_knowledge, query_knowledge, write_knowledge, update_knowledge, init_knowledge.\"\n" +
"description: \"Use the persistent knowledge store for project conventions, subsystems, decisions, and workflows. Triggers on: what are our conventions, does this project have rules about, how does this subsystem work, why was this decision made, run the brainstorm workflow, run the plan workflow, debug workflow, verify workflow, capture this decision, write that down as knowledge, update that entry, knowledge store, .agents, list_knowledge, query_knowledge, write_knowledge, update_knowledge, init_knowledge.\"\n" +
"---\n" +
"\n" +
"# Knowledge Store (knowledge-mcp)\n" +
Expand Down
4 changes: 3 additions & 1 deletion knowledge/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func Today() string {

// ValidCategories returns the set of valid category names.
func ValidCategories() []string {
return []string{"conventions", "subsystems", "decisions"}
return []string{"conventions", "subsystems", "decisions", "workflows"}
}

// CategoryPrefix maps category names to their ID prefixes.
Expand All @@ -50,6 +50,8 @@ func CategoryPrefix(category string) string {
return "sub"
case "decisions":
return "dec"
case "workflows":
return "wf"
default:
return ""
}
Expand Down
11 changes: 11 additions & 0 deletions knowledge/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,23 @@ func TestValidationHelpers(t *testing.T) {
if !IsValidCategory("conventions") {
t.Error("conventions should be valid")
}
if !IsValidCategory("workflows") {
t.Error("workflows should be valid")
}
if IsValidCategory("invalid") {
t.Error("invalid should not be valid")
}
if CategoryPrefix("conventions") != "conv" {
t.Errorf("prefix = %q, want %q", CategoryPrefix("conventions"), "conv")
}
if CategoryPrefix("workflows") != "wf" {
t.Errorf("prefix = %q, want %q", CategoryPrefix("workflows"), "wf")
}
for _, cat := range ValidCategories() {
if CategoryPrefix(cat) == "" {
t.Errorf("category %q has no ID prefix", cat)
}
}
}

func TestEnsureDir(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion tools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ func ListHandler(res *projects.Resolver, idx *search.Index) func(context.Context

filterCategory := request.GetString("category", "")
if filterCategory != "" && !knowledge.IsValidCategory(filterCategory) {
return mcp.NewToolResultError(fmt.Sprintf("invalid category: %q (must be conventions, subsystems, or decisions)", filterCategory)), nil
return mcp.NewToolResultError(fmt.Sprintf(
"invalid category: %q (must be %s)", filterCategory, strings.Join(knowledge.ValidCategories(), ", "))), nil
}

output := ""
Expand Down
4 changes: 3 additions & 1 deletion tools/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
Expand All @@ -29,7 +30,8 @@ func QueryHandler(res *projects.Resolver, idx *search.Index) func(context.Contex
limit := int(request.GetFloat("limit", 10))

if category != "" && !knowledge.IsValidCategory(category) {
return mcp.NewToolResultError(fmt.Sprintf("invalid category: %q (must be conventions, subsystems, or decisions)", category)), nil
return mcp.NewToolResultError(fmt.Sprintf(
"invalid category: %q (must be %s)", category, strings.Join(knowledge.ValidCategories(), ", "))), nil
}

results, err := idx.Query(ref.Address, query, category, limit)
Expand Down
21 changes: 16 additions & 5 deletions tools/registry.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
package tools

import "github.com/mark3labs/mcp-go/mcp"
import (
"fmt"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
)

// categoryNames keeps tool descriptions in sync with ValidCategories.
func categoryNames() string {
return strings.Join(knowledge.ValidCategories(), ", ")
}

// Tool name constants keep main.go, the registry, and generated
// instructions in sync.
Expand Down Expand Up @@ -43,7 +54,7 @@ var Registry = []ToolSpec{
Description: "Add a new knowledge entry to a project's .agents/ store",
Params: []ParamSpec{
{Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true},
{Name: "category", Description: "Category: conventions, subsystems, or decisions", Type: "string", Required: true},
{Name: "category", Description: fmt.Sprintf("Category: %s", categoryNames()), Type: "string", Required: true},
{Name: "summary", Description: "One-line description (max 100 chars)", Type: "string", Required: true},
{Name: "detail", Description: "Concise knowledge (10-20 lines). Summarize key facts, don't copy source files. Include only query-able information.", Type: "string", Required: true},
{Name: "source", Description: "How this was learned (provenance)", Type: "string", Required: true},
Expand All @@ -56,7 +67,7 @@ var Registry = []ToolSpec{
Params: []ParamSpec{
{Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true},
{Name: "query", Description: "Full-text search query", Type: "string"},
{Name: "category", Description: "Filter by category: conventions, subsystems, or decisions", Type: "string"},
{Name: "category", Description: fmt.Sprintf("Filter by category: %s", categoryNames()), Type: "string"},
{Name: "limit", Description: "Max results (default 10)", Type: "number"},
},
},
Expand All @@ -65,15 +76,15 @@ var Registry = []ToolSpec{
Description: "List all knowledge entries for a project. Entries with a rule are shown first under constraints.",
Params: []ParamSpec{
{Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true},
{Name: "category", Description: "Filter by category: conventions, subsystems, or decisions", Type: "string"},
{Name: "category", Description: fmt.Sprintf("Filter by category: %s", categoryNames()), Type: "string"},
},
},
{
Name: ToolUpdateKnowledge, Purpose: "Update an existing knowledge entry by ID",
Description: "Update an existing knowledge entry by ID",
Params: []ParamSpec{
{Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true},
{Name: "category", Description: "Category: conventions, subsystems, or decisions", Type: "string", Required: true},
{Name: "category", Description: fmt.Sprintf("Category: %s", categoryNames()), Type: "string", Required: true},
{Name: "id", Description: "Entry ID to update (e.g., conv-001)", Type: "string", Required: true},
{Name: "summary", Description: "New summary (optional)", Type: "string"},
{Name: "detail", Description: "New detail (optional)", Type: "string"},
Expand Down
52 changes: 52 additions & 0 deletions tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,58 @@ func TestWriteHandler(t *testing.T) {
}
}

func TestWriteHandlerWorkflowsCategory(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "test")
agentsDir := filepath.Join(dir, ".agents")
knowledge.EnsureDir(agentsDir)

idx, _ := search.NewIndex()
defer idx.Close()

resolver, _, err := projects.Build([]string{root}, nil, "")
if err != nil {
t.Fatalf("projects.Build() error: %v", err)
}

handler := WriteHandler(resolver, idx)

req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"project": "test",
"category": "workflows",
"summary": "Brainstorm before building",
"detail": "Ask one question at a time and play back what you heard.",
"source": "test",
"rule": "Do not build until the user approves the description",
}

result, err := handler(context.Background(), req)
if err != nil {
t.Fatalf("WriteHandler() error: %v", err)
}
if result == nil {
t.Fatal("WriteHandler() returned nil")
}
if result.IsError {
t.Fatal("WriteHandler() returned an error result for the workflows category")
}

loaded, loadErr := knowledge.Load(knowledge.CategoryFilePath(agentsDir, "workflows"))
if loadErr != nil {
t.Fatalf("Load() error: %v", loadErr)
}
if len(loaded.Entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(loaded.Entries))
}
if loaded.Entries[0].ID != "wf-001" {
t.Errorf("ID = %q, want %q", loaded.Entries[0].ID, "wf-001")
}
if loaded.Entries[0].Rule == "" {
t.Error("Rule should not be empty")
}
}

func TestWriteHandlerValidation(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "test")
Expand Down
4 changes: 3 additions & 1 deletion tools/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
Expand All @@ -24,7 +25,8 @@ func UpdateHandler(res *projects.Resolver, idx *search.Index) func(context.Conte
return mcp.NewToolResultError(err.Error()), nil
}
if !knowledge.IsValidCategory(category) {
return mcp.NewToolResultError(fmt.Sprintf("invalid category: %q (must be conventions, subsystems, or decisions)", category)), nil
return mcp.NewToolResultError(fmt.Sprintf(
"invalid category: %q (must be %s)", category, strings.Join(knowledge.ValidCategories(), ", "))), nil
}

id, err := request.RequireString("id")
Expand Down
4 changes: 3 additions & 1 deletion tools/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
Expand All @@ -28,7 +29,8 @@ func WriteHandler(res *projects.Resolver, idx *search.Index) func(context.Contex
return mcp.NewToolResultError(err.Error()), nil
}
if !knowledge.IsValidCategory(category) {
return mcp.NewToolResultError(fmt.Sprintf("invalid category: %q (must be conventions, subsystems, or decisions)", category)), nil
return mcp.NewToolResultError(fmt.Sprintf(
"invalid category: %q (must be %s)", category, strings.Join(knowledge.ValidCategories(), ", "))), nil
}

summary, err := request.RequireString("summary")
Expand Down
Loading