Slice 3: activity feed and active project (SSE + MCP tools)

Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 08:16:42 -04:00
parent d3abd4416e
commit 65daedc902
12 changed files with 579 additions and 11 deletions
+43
View File
@@ -12,6 +12,7 @@ import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
@@ -28,6 +29,21 @@ type listReposOutput struct {
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
}
// setActiveProjectInput is the argument schema for set_active_project.
type setActiveProjectInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"`
}
// activeProjectOutput reports the active project path (object, per the rule above).
type activeProjectOutput struct {
Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"`
}
// activityOutput wraps the activity feed (object, per the rule above).
type activityOutput struct {
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -57,6 +73,33 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, detail, nil
})
// get_active_project — the repo/task currently in focus (§8.2).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_active_project",
Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// set_active_project — Claude switches the focus to another repo.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "set_active_project",
Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil {
return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err)
}
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// get_activity — recent user + Claude actions, so Claude can catch up.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_activity",
Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) {
return nil, activityOutput{Events: svc.Activity(50)}, nil
})
return s
}
+60 -1
View File
@@ -13,6 +13,7 @@ import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/git"
"gitmanager/internal/repos"
"gitmanager/internal/service"
@@ -41,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index)
svc := service.New(g, scanner.Index, activity.New(log, 200))
srv := NewServer(svc, "test")
// Wire an in-memory client<->server session.
@@ -100,6 +101,64 @@ func TestMCPRoundTrip(t *testing.T) {
if !res.IsError {
t.Fatalf("expected IsError for unknown repo, got success")
}
// active project starts empty.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
if err != nil {
t.Fatalf("get_active_project: %v", err)
}
var ap struct {
Path string `json:"path"`
}
decodeResult(t, res, &ap)
if ap.Path != "" {
t.Fatalf("expected empty active project, got %q", ap.Path)
}
// set_active_project to our repo, then read it back.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("set_active_project: %v", err)
}
if res.IsError {
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
decodeResult(t, res, &ap)
if ap.Path != states[0].Path {
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
}
// setting an unknown project is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("set_active_project(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown active project")
}
// the activity feed should now contain the active-project-changed event.
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
var act struct {
Events []activity.Event `json:"events"`
}
decodeResult(t, res, &act)
found := false
for _, ev := range act.Events {
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
found = true
}
}
if !found {
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.