Skip to content

Commit a54e1b8

Browse files
author
codevalve
committed
feat(mcp): add local-only MCP server with stdio transport
1 parent 07ca6b2 commit a54e1b8

10 files changed

Lines changed: 1044 additions & 32 deletions

File tree

.agents/workflows/prd-mcp.md

Lines changed: 471 additions & 0 deletions
Large diffs are not rendered by default.

.agents/workflows/setup-mcp.md

Lines changed: 0 additions & 28 deletions
This file was deleted.

.agents/workflows/triage-issues.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ For each triage action:
3232
## 4. Work Dispatch
3333
- [ ] Update `ROADMAP.md` if the issue is high-priority for the next release.
3434
- [ ] Ask the USER to approve the plan before execution.
35+
36+
## 5. Environment Check
37+
// turbo
38+
- [ ] Ensure local git config is correct:
39+
`git config user.email "john.lovell@codevalve.com" && git config user.name "codevalve"`

ROADMAP.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,23 @@ Bridging the gap between local-only and cloud-hosted logs by leveraging private
3030
- **Clipboard Support**: Robust handling for multi-character pastes in the terminal.
3131
- **Dynamic Layout**: Constrained collection column width with automatic truncation.
3232

33-
## v2.7.0: Documentation & Onboarding
33+
## v2.7.2: Security Fix (Released 🔒🚀)
34+
- **Fixes**:
35+
- **esbuild**: Resolved a security vulnerability in `esbuild` using npm overrides in the documentation site.
36+
37+
## v2.7.1: Build & Security (Released 🛠️🚀)
38+
- **Fixes**:
39+
- **CI/CD Align**: Updated Go to 1.25 and Node to 22 in GitHub Actions.
40+
- **Security**: Added Dependabot configuration for automated dependency tracking.
41+
42+
## v2.7.0: Documentation & Onboarding (Released 📖🚀)
3443
The goal is to lower the barrier to entry and ensure Rapide feels accessible to new users while maintaining its professional edge.
3544

3645
- **Infrastructure**: Refined internal documentation and self-documenting CLI help.
3746
- **Features**:
38-
- **Interactive Tutorial**: A guided first-run experience within the TUI.
39-
- **The "Context" system**: Enhanced in-app help (press `?`) that explains Rapide's syntax (bullets, margin keys) in real-time.
40-
- **Example Collections**: Optionally seed the journal with best-practice templates (e.g., Work, Health, Ideas) on setup.
47+
- **`rapide init`**: Interactive setup wizard to seed your journal.
48+
- **TUI Help Overlay**: In-app quick reference (press `?`).
49+
- **VitePress Documentation**: Dedicated docs site on GitHub Pages.
4150

4251
## v3.0.0: Rapide MCP (Model Context Protocol)
4352
Bridging the gap between your logs and AI agents by making Rapide a first-class MCP server.

cmd/mcp.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"rapide/internal/mcp"
9+
"rapide/internal/storage"
10+
"syscall"
11+
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var mcpCmd = &cobra.Command{
16+
Use: "mcp",
17+
Short: "MCP server commands",
18+
Hidden: true,
19+
}
20+
21+
var mcpStartCmd = &cobra.Command{
22+
Use: "start",
23+
Short: "Start the MCP server",
24+
Hidden: true,
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
s, err := storage.NewStorage()
27+
if err != nil {
28+
return fmt.Errorf("failed to initialize storage: %w", err)
29+
}
30+
31+
adapter := mcp.NewJournalAdapter(s)
32+
server := mcp.NewServer(adapter)
33+
34+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
35+
defer stop()
36+
37+
fmt.Fprintln(os.Stderr, "Starting Rapide MCP server (stdio)...")
38+
return server.Start(ctx)
39+
},
40+
}
41+
42+
func init() {
43+
rootCmd.AddCommand(mcpCmd)
44+
mcpCmd.AddCommand(mcpStartCmd)
45+
}

internal/mcp/adapter.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"rapide/internal/model"
6+
"rapide/internal/storage"
7+
"strings"
8+
"time"
9+
)
10+
11+
type JournalAdapter interface {
12+
AddAgentEntry(ctx context.Context, content string) (*model.Entry, error)
13+
SearchAgentEntries(ctx context.Context, query string) ([]model.Entry, error)
14+
ListRecentAgentEntries(ctx context.Context, limit int) ([]model.Entry, error)
15+
}
16+
17+
type storageAdapter struct {
18+
storage *storage.Storage
19+
}
20+
21+
func NewJournalAdapter(s *storage.Storage) JournalAdapter {
22+
return &storageAdapter{storage: s}
23+
}
24+
25+
func (a *storageAdapter) AddAgentEntry(ctx context.Context, content string) (*model.Entry, error) {
26+
entry := model.Entry{
27+
Timestamp: time.Now(),
28+
MarginKey: "AGENT",
29+
Bullet: "•",
30+
Content: content,
31+
Priority: false,
32+
Pinned: false,
33+
}
34+
35+
id, err := a.storage.Append(entry)
36+
if err != nil {
37+
return nil, err
38+
}
39+
entry.ID = id
40+
return &entry, nil
41+
}
42+
43+
func (a *storageAdapter) SearchAgentEntries(ctx context.Context, query string) ([]model.Entry, error) {
44+
all, err := a.storage.List()
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
var results []model.Entry
50+
q := strings.ToLower(query)
51+
for _, e := range all {
52+
if e.MarginKey == "AGENT" && strings.Contains(strings.ToLower(e.Content), q) {
53+
results = append(results, e)
54+
}
55+
}
56+
return results, nil
57+
}
58+
59+
func (a *storageAdapter) ListRecentAgentEntries(ctx context.Context, limit int) ([]model.Entry, error) {
60+
all, err := a.storage.List()
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
var agentEntries []model.Entry
66+
for i := len(all) - 1; i >= 0; i-- {
67+
if all[i].MarginKey == "AGENT" {
68+
agentEntries = append(agentEntries, all[i])
69+
if len(agentEntries) >= limit {
70+
break
71+
}
72+
}
73+
}
74+
return agentEntries, nil
75+
}

internal/mcp/adapter_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"rapide/internal/storage"
8+
"testing"
9+
)
10+
11+
func setupTestStorage(t *testing.T) (*storage.Storage, func()) {
12+
tmpDir, err := os.MkdirTemp("", "rapide-mcp-test-*")
13+
if err != nil {
14+
t.Fatalf("failed to create temp dir: %v", err)
15+
}
16+
17+
dbPath := filepath.Join(tmpDir, "entries.jsonl")
18+
s := &storage.Storage{FilePath: dbPath}
19+
20+
return s, func() {
21+
os.RemoveAll(tmpDir)
22+
}
23+
}
24+
25+
func TestAdapter_AddAgentEntry(t *testing.T) {
26+
s, cleanup := setupTestStorage(t)
27+
defer cleanup()
28+
29+
adapter := NewJournalAdapter(s)
30+
content := "test mcp entry"
31+
32+
entry, err := adapter.AddAgentEntry(context.Background(), content)
33+
if err != nil {
34+
t.Fatalf("AddAgentEntry failed: %v", err)
35+
}
36+
37+
if entry.Content != content {
38+
t.Errorf("expected content %q, got %q", content, entry.Content)
39+
}
40+
if entry.MarginKey != "AGENT" {
41+
t.Errorf("expected margin key AGENT, got %q", entry.MarginKey)
42+
}
43+
}
44+
45+
func TestAdapter_SearchAgentEntries(t *testing.T) {
46+
s, cleanup := setupTestStorage(t)
47+
defer cleanup()
48+
49+
adapter := NewJournalAdapter(s)
50+
adapter.AddAgentEntry(context.Background(), "apple pie")
51+
adapter.AddAgentEntry(context.Background(), "banana split")
52+
53+
results, err := adapter.SearchAgentEntries(context.Background(), "banana")
54+
if err != nil {
55+
t.Fatalf("SearchAgentEntries failed: %v", err)
56+
}
57+
58+
if len(results) != 1 {
59+
t.Fatalf("expected 1 result, got %d", len(results))
60+
}
61+
if results[0].Content != "banana split" {
62+
t.Errorf("expected 'banana split', got %q", results[0].Content)
63+
}
64+
}
65+
66+
func TestAdapter_ListRecentAgentEntries(t *testing.T) {
67+
s, cleanup := setupTestStorage(t)
68+
defer cleanup()
69+
70+
adapter := NewJournalAdapter(s)
71+
for i := 0; i < 5; i++ {
72+
adapter.AddAgentEntry(context.Background(), "entry")
73+
}
74+
75+
results, err := adapter.ListRecentAgentEntries(context.Background(), 3)
76+
if err != nil {
77+
t.Fatalf("ListRecentAgentEntries failed: %v", err)
78+
}
79+
80+
if len(results) != 3 {
81+
t.Errorf("expected 3 results, got %d", len(results))
82+
}
83+
}

internal/mcp/handlers.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
)
8+
9+
func (s *Server) toolAddEntry(ctx context.Context, args json.RawMessage) (interface{}, *Error) {
10+
var a AddEntryArgs
11+
if err := json.Unmarshal(args, &a); err != nil {
12+
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
13+
}
14+
15+
if a.Content == "" {
16+
return nil, &Error{Code: -32602, Message: "Content is required"}
17+
}
18+
19+
entry, err := s.adapter.AddAgentEntry(ctx, a.Content)
20+
if err != nil {
21+
return nil, &Error{Code: -32000, Message: fmt.Sprintf("Failed to add entry: %v", err)}
22+
}
23+
24+
return CallToolResult{
25+
Content: []TextContent{
26+
{
27+
Type: "text",
28+
Text: fmt.Sprintf("Added entry with ID %s to AGENT collection", entry.ID),
29+
},
30+
},
31+
}, nil
32+
}
33+
34+
func (s *Server) toolSearchEntries(ctx context.Context, args json.RawMessage) (interface{}, *Error) {
35+
var a SearchEntriesArgs
36+
if err := json.Unmarshal(args, &a); err != nil {
37+
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
38+
}
39+
40+
entries, err := s.adapter.SearchAgentEntries(ctx, a.Query)
41+
if err != nil {
42+
return nil, &Error{Code: -32000, Message: fmt.Sprintf("Failed to search entries: %v", err)}
43+
}
44+
45+
data, _ := json.MarshalIndent(entries, "", " ")
46+
return CallToolResult{
47+
Content: []TextContent{
48+
{
49+
Type: "text",
50+
Text: string(data),
51+
},
52+
},
53+
}, nil
54+
}
55+
56+
func (s *Server) toolListRecent(ctx context.Context, args json.RawMessage) (interface{}, *Error) {
57+
var a ListRecentArgs
58+
if err := json.Unmarshal(args, &a); err != nil {
59+
// Fallback to default if unmarshal fails (e.g. empty args)
60+
a.Limit = 10
61+
}
62+
if a.Limit <= 0 {
63+
a.Limit = 10
64+
}
65+
if a.Limit > 50 {
66+
a.Limit = 50
67+
}
68+
69+
entries, err := s.adapter.ListRecentAgentEntries(ctx, a.Limit)
70+
if err != nil {
71+
return nil, &Error{Code: -32000, Message: fmt.Sprintf("Failed to list entries: %v", err)}
72+
}
73+
74+
data, _ := json.MarshalIndent(entries, "", " ")
75+
return CallToolResult{
76+
Content: []TextContent{
77+
{
78+
Type: "text",
79+
Text: string(data),
80+
},
81+
},
82+
}, nil
83+
}

0 commit comments

Comments
 (0)