feat(mcp): RFC §3 tools + §8 discovery alignment

New tools (RFC §3):
- webview_render / webview_update: embedded UI HTML + state broadcast
  via webview.render / webview.update channels with merge-or-replace
- ws_connect / ws_send / ws_close: outbound WebSocket client tools
  with stable ws-<hex> connection IDs
- process_run: blocking command executor returning ID/exit/output
- rag_search / rag_index: aliases for rag_query / rag_ingest per spec
- rag_retrieve: fetch chunks for a source, ordered by chunk index
- ide_dashboard_state / ide_dashboard_update: merge-or-replace state
  with activity feed entries and dashboard.state.updated broadcast
- agentic_issue_dispatch: spec-aligned name for agentic_dispatch_issue

Discovery (RFC §8.2):
- transport_http.go: /.well-known/mcp-servers.json advertises both
  core-agent and core-mcp with semantic use_when hints

Tool count: 25 → 33. Good/Bad/Ugly coverage added for every new tool.

Pre-existing cmd/mcpcmd Cobra-style build error flagged but untouched
— same cmd vs core.Command migration pattern seen in cmd/api and
cmd/build (which were migrated earlier this session).

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 15:55:15 +01:00
parent 92727025e7
commit ca07b6cd62
15 changed files with 1398 additions and 11 deletions

View file

@ -49,6 +49,12 @@ func (s *PrepSubsystem) registerIssueTools(svc *coremcp.Service) {
Description: "Dispatch an agent to work on a Forge issue. Assigns the issue as a lock, prepends the issue body to TODO.md, creates an issue-specific branch, and spawns the agent.",
}, s.dispatchIssue)
// agentic_issue_dispatch is the spec-aligned name for the same action.
coremcp.AddToolRecorded(svc, server, "agentic", &mcp.Tool{
Name: "agentic_issue_dispatch",
Description: "Dispatch an agent to work on a Forge issue. Spec-aligned alias for agentic_dispatch_issue.",
}, s.dispatchIssue)
coremcp.AddToolRecorded(svc, server, "agentic", &mcp.Tool{
Name: "agentic_pr",
Description: "Create a pull request from an agent workspace. Pushes the branch and creates a Forge PR linked to the tracked issue, if any.",

View file

@ -4,6 +4,7 @@ package ide
import (
"context"
"sync"
"time"
coremcp "dappco.re/go/mcp/pkg/mcp"
@ -86,6 +87,46 @@ type DashboardMetricsOutput struct {
Metrics DashboardMetrics `json:"metrics"`
}
// DashboardStateInput is the input for ide_dashboard_state.
//
// input := DashboardStateInput{}
type DashboardStateInput struct{}
// DashboardStateOutput is the output for ide_dashboard_state.
//
// // out.State["theme"] == "dark"
type DashboardStateOutput struct {
State map[string]any `json:"state"` // arbitrary key/value map
UpdatedAt time.Time `json:"updatedAt"` // when the state last changed
}
// DashboardUpdateInput is the input for ide_dashboard_update.
//
// input := DashboardUpdateInput{
// State: map[string]any{"theme": "light", "sidebar": true},
// Replace: false,
// }
type DashboardUpdateInput struct {
State map[string]any `json:"state"` // partial or full state
Replace bool `json:"replace,omitempty"` // true to overwrite, false to merge (default)
}
// DashboardUpdateOutput is the output for ide_dashboard_update.
//
// // out.State reflects the merged/replaced state
type DashboardUpdateOutput struct {
State map[string]any `json:"state"` // merged state after the update
UpdatedAt time.Time `json:"updatedAt"` // when the state was applied
}
// dashboardStateStore holds the mutable dashboard UI state shared between the
// IDE frontend and MCP callers. Access is guarded by dashboardStateMu.
var (
dashboardStateMu sync.RWMutex
dashboardStateStore = map[string]any{}
dashboardStateUpdated time.Time
)
func (s *Subsystem) registerDashboardTools(svc *coremcp.Service) {
server := svc.Server()
coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{
@ -102,6 +143,16 @@ func (s *Subsystem) registerDashboardTools(svc *coremcp.Service) {
Name: "ide_dashboard_metrics",
Description: "Get aggregate build and agent metrics for a time period",
}, s.dashboardMetrics)
coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{
Name: "ide_dashboard_state",
Description: "Get the current dashboard UI state (arbitrary key/value map shared with the IDE).",
}, s.dashboardState)
coremcp.AddToolRecorded(svc, server, "ide", &mcp.Tool{
Name: "ide_dashboard_update",
Description: "Update the dashboard UI state. Merges into existing state by default; set replace=true to overwrite.",
}, s.dashboardUpdate)
}
// dashboardOverview returns a platform overview with bridge status and
@ -211,3 +262,79 @@ func (s *Subsystem) dashboardMetrics(_ context.Context, _ *mcp.CallToolRequest,
},
}, nil
}
// dashboardState returns the current dashboard UI state as a snapshot.
//
// out := s.dashboardState(ctx, nil, DashboardStateInput{})
func (s *Subsystem) dashboardState(_ context.Context, _ *mcp.CallToolRequest, _ DashboardStateInput) (*mcp.CallToolResult, DashboardStateOutput, error) {
dashboardStateMu.RLock()
defer dashboardStateMu.RUnlock()
snapshot := make(map[string]any, len(dashboardStateStore))
for k, v := range dashboardStateStore {
snapshot[k] = v
}
return nil, DashboardStateOutput{
State: snapshot,
UpdatedAt: dashboardStateUpdated,
}, nil
}
// dashboardUpdate merges or replaces the dashboard UI state and emits an
// activity event so the IDE can react to the change.
//
// out := s.dashboardUpdate(ctx, nil, DashboardUpdateInput{State: map[string]any{"theme": "dark"}})
func (s *Subsystem) dashboardUpdate(ctx context.Context, _ *mcp.CallToolRequest, input DashboardUpdateInput) (*mcp.CallToolResult, DashboardUpdateOutput, error) {
now := time.Now()
dashboardStateMu.Lock()
if input.Replace || dashboardStateStore == nil {
dashboardStateStore = make(map[string]any, len(input.State))
}
for k, v := range input.State {
dashboardStateStore[k] = v
}
dashboardStateUpdated = now
snapshot := make(map[string]any, len(dashboardStateStore))
for k, v := range dashboardStateStore {
snapshot[k] = v
}
dashboardStateMu.Unlock()
// Record the change on the activity feed so ide_dashboard_activity
// reflects state transitions alongside build/session events.
s.recordActivity("dashboard_state", "dashboard state updated")
// Push the update over the Laravel bridge when available so web clients
// stay in sync with desktop tooling.
if s.bridge != nil {
_ = s.bridge.Send(BridgeMessage{
Type: "dashboard_update",
Data: snapshot,
})
}
// Surface the change on the shared MCP notifier so connected sessions
// receive a JSON-RPC notification alongside the tool response.
if s.notifier != nil {
s.notifier.ChannelSend(ctx, "dashboard.state.updated", map[string]any{
"state": snapshot,
"updatedAt": now,
})
}
return nil, DashboardUpdateOutput{
State: snapshot,
UpdatedAt: now,
}, nil
}
// resetDashboardState clears the shared dashboard state. Intended for tests.
func resetDashboardState() {
dashboardStateMu.Lock()
defer dashboardStateMu.Unlock()
dashboardStateStore = map[string]any{}
dashboardStateUpdated = time.Time{}
}

View file

@ -949,3 +949,76 @@ func TestChatSend_Good_BridgeMessageType(t *testing.T) {
t.Fatal("timed out waiting for bridge message")
}
}
// TestToolsDashboard_DashboardState_Good returns an empty state when the
// store has not been touched.
func TestToolsDashboard_DashboardState_Good(t *testing.T) {
t.Cleanup(resetDashboardState)
sub := newNilBridgeSubsystem()
_, out, err := sub.dashboardState(context.Background(), nil, DashboardStateInput{})
if err != nil {
t.Fatalf("dashboardState failed: %v", err)
}
if len(out.State) != 0 {
t.Fatalf("expected empty state, got %v", out.State)
}
}
// TestToolsDashboard_DashboardUpdate_Good merges the supplied state into the
// shared store and reflects it back on a subsequent dashboardState call.
func TestToolsDashboard_DashboardUpdate_Good(t *testing.T) {
t.Cleanup(resetDashboardState)
sub := newNilBridgeSubsystem()
_, updateOut, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{
State: map[string]any{"theme": "dark"},
})
if err != nil {
t.Fatalf("dashboardUpdate failed: %v", err)
}
if updateOut.State["theme"] != "dark" {
t.Fatalf("expected theme 'dark', got %v", updateOut.State["theme"])
}
_, readOut, err := sub.dashboardState(context.Background(), nil, DashboardStateInput{})
if err != nil {
t.Fatalf("dashboardState failed: %v", err)
}
if readOut.State["theme"] != "dark" {
t.Fatalf("expected persisted theme 'dark', got %v", readOut.State["theme"])
}
if readOut.UpdatedAt.IsZero() {
t.Fatal("expected non-zero UpdatedAt after update")
}
}
// TestToolsDashboard_DashboardUpdate_Ugly replaces (not merges) prior state
// when Replace=true.
func TestToolsDashboard_DashboardUpdate_Ugly(t *testing.T) {
t.Cleanup(resetDashboardState)
sub := newNilBridgeSubsystem()
_, _, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{
State: map[string]any{"theme": "dark", "sidebar": true},
})
if err != nil {
t.Fatalf("seed dashboardUpdate failed: %v", err)
}
_, out, err := sub.dashboardUpdate(context.Background(), nil, DashboardUpdateInput{
State: map[string]any{"theme": "light"},
Replace: true,
})
if err != nil {
t.Fatalf("replace dashboardUpdate failed: %v", err)
}
if _, ok := out.State["sidebar"]; ok {
t.Fatal("expected sidebar to be removed after replace")
}
if out.State["theme"] != "light" {
t.Fatalf("expected theme 'light', got %v", out.State["theme"])
}
}

View file

@ -316,6 +316,7 @@ func (s *Service) registerTools(server *mcp.Server) {
s.registerProcessTools(server)
s.registerWebviewTools(server)
s.registerWSTools(server)
s.registerWSClientTools(server)
}
// Tool input/output types for MCP file operations.

View file

@ -71,13 +71,19 @@ func TestToolRegistry_Good_ToolCount(t *testing.T) {
}
tools := svc.Tools()
// Built-in tools: file_read, file_write, file_delete, file_rename,
// file_exists, file_edit, dir_list, dir_create, lang_detect, lang_list,
// metrics_record, metrics_query, rag_query, rag_ingest, rag_collections,
// webview_connect, webview_disconnect, webview_navigate, webview_click,
// webview_type, webview_query, webview_console, webview_eval,
// webview_screenshot, webview_wait
const expectedCount = 25
// Built-in tools (no ProcessService / WSHub / Subsystems):
// files (8): file_read, file_write, file_delete, file_rename,
// file_exists, file_edit, dir_list, dir_create
// language (2): lang_detect, lang_list
// metrics (2): metrics_record, metrics_query
// rag (6): rag_query, rag_search, rag_ingest, rag_index,
// rag_retrieve, rag_collections
// webview (12): webview_connect, webview_disconnect, webview_navigate,
// webview_click, webview_type, webview_query,
// webview_console, webview_eval, webview_screenshot,
// webview_wait, webview_render, webview_update
// ws (3): ws_connect, ws_send, ws_close
const expectedCount = 33
if len(tools) != expectedCount {
t.Errorf("expected %d tools, got %d", expectedCount, len(tools))
for _, tr := range tools {
@ -95,8 +101,8 @@ func TestToolRegistry_Good_GroupAssignment(t *testing.T) {
fileTools := []string{"file_read", "file_write", "file_delete", "file_rename", "file_exists", "file_edit", "dir_list", "dir_create"}
langTools := []string{"lang_detect", "lang_list"}
metricsTools := []string{"metrics_record", "metrics_query"}
ragTools := []string{"rag_query", "rag_ingest", "rag_collections"}
webviewTools := []string{"webview_connect", "webview_disconnect", "webview_navigate", "webview_click", "webview_type", "webview_query", "webview_console", "webview_eval", "webview_screenshot", "webview_wait"}
ragTools := []string{"rag_query", "rag_search", "rag_ingest", "rag_index", "rag_retrieve", "rag_collections"}
webviewTools := []string{"webview_connect", "webview_disconnect", "webview_navigate", "webview_click", "webview_type", "webview_query", "webview_console", "webview_eval", "webview_screenshot", "webview_wait", "webview_render", "webview_update"}
byName := make(map[string]ToolRecord)
for _, tr := range svc.Tools() {
@ -157,6 +163,18 @@ func TestToolRegistry_Good_GroupAssignment(t *testing.T) {
t.Errorf("tool %s: expected group 'webview', got %q", name, tr.Group)
}
}
wsClientTools := []string{"ws_connect", "ws_send", "ws_close"}
for _, name := range wsClientTools {
tr, ok := byName[name]
if !ok {
t.Errorf("tool %s not found in registry", name)
continue
}
if tr.Group != "ws" {
t.Errorf("tool %s: expected group 'ws', got %q", name, tr.Group)
}
}
}
func TestToolRegistry_Good_ToolRecordFields(t *testing.T) {

View file

@ -29,6 +29,32 @@ type ProcessStartInput struct {
Env []string `json:"env,omitempty"` // e.g. ["CGO_ENABLED=0"]
}
// ProcessRunInput contains parameters for running a command to completion
// and returning its captured output.
//
// input := ProcessRunInput{
// Command: "go",
// Args: []string{"test", "./..."},
// Dir: "/home/user/project",
// Env: []string{"CGO_ENABLED=0"},
// }
type ProcessRunInput struct {
Command string `json:"command"` // e.g. "go"
Args []string `json:"args,omitempty"` // e.g. ["test", "./..."]
Dir string `json:"dir,omitempty"` // e.g. "/home/user/project"
Env []string `json:"env,omitempty"` // e.g. ["CGO_ENABLED=0"]
}
// ProcessRunOutput contains the result of running a process to completion.
//
// // out.ID == "proc-abc123", out.ExitCode == 0, out.Output == "PASS\n..."
type ProcessRunOutput struct {
ID string `json:"id"` // e.g. "proc-abc123"
ExitCode int `json:"exitCode"` // 0 on success
Output string `json:"output"` // combined stdout/stderr
Command string `json:"command"` // e.g. "go"
}
// ProcessStartOutput contains the result of starting a process.
//
// // out.ID == "proc-abc123", out.PID == 54321, out.Command == "go"
@ -146,6 +172,11 @@ func (s *Service) registerProcessTools(server *mcp.Server) bool {
Description: "Start a new external process. Returns process ID for tracking.",
}, s.processStart)
addToolRecorded(s, server, "process", &mcp.Tool{
Name: "process_run",
Description: "Run a command to completion and return the captured output. Blocks until the process exits.",
}, s.processRun)
addToolRecorded(s, server, "process", &mcp.Tool{
Name: "process_stop",
Description: "Gracefully stop a running process by ID.",
@ -224,6 +255,63 @@ func (s *Service) processStart(ctx context.Context, req *mcp.CallToolRequest, in
return nil, output, nil
}
// processRun handles the process_run tool call.
// Executes the command to completion and returns the captured output.
func (s *Service) processRun(ctx context.Context, req *mcp.CallToolRequest, input ProcessRunInput) (*mcp.CallToolResult, ProcessRunOutput, error) {
if s.processService == nil {
return nil, ProcessRunOutput{}, log.E("processRun", "process service unavailable", nil)
}
s.logger.Security("MCP tool execution", "tool", "process_run", "command", input.Command, "args", input.Args, "dir", input.Dir, "user", log.Username())
if input.Command == "" {
return nil, ProcessRunOutput{}, log.E("processRun", "command cannot be empty", nil)
}
opts := process.RunOptions{
Command: input.Command,
Args: input.Args,
Dir: s.resolveWorkspacePath(input.Dir),
Env: input.Env,
}
proc, err := s.processService.StartWithOptions(ctx, opts)
if err != nil {
log.Error("mcp: process run start failed", "command", input.Command, "err", err)
return nil, ProcessRunOutput{}, log.E("processRun", "failed to start process", err)
}
info := proc.Info()
s.recordProcessRuntime(proc.ID, processRuntime{
Command: proc.Command,
Args: proc.Args,
Dir: info.Dir,
StartedAt: proc.StartedAt,
})
s.ChannelSend(ctx, ChannelProcessStart, map[string]any{
"id": proc.ID,
"pid": info.PID,
"command": proc.Command,
"args": proc.Args,
"dir": info.Dir,
"startedAt": proc.StartedAt,
})
// Wait for completion (context-aware).
select {
case <-ctx.Done():
return nil, ProcessRunOutput{}, log.E("processRun", "cancelled", ctx.Err())
case <-proc.Done():
}
return nil, ProcessRunOutput{
ID: proc.ID,
ExitCode: proc.ExitCode,
Output: proc.Output(),
Command: proc.Command,
}, nil
}
// processStop handles the process_stop tool call.
func (s *Service) processStop(ctx context.Context, req *mcp.CallToolRequest, input ProcessStopInput) (*mcp.CallToolResult, ProcessStopOutput, error) {
if s.processService == nil {

View file

@ -301,3 +301,57 @@ func TestRegisterProcessTools_Bad_NilService(t *testing.T) {
t.Error("Expected registerProcessTools to return false when processService is nil")
}
}
// TestToolsProcess_ProcessRunInput_Good exercises the process_run input DTO shape.
func TestToolsProcess_ProcessRunInput_Good(t *testing.T) {
input := ProcessRunInput{
Command: "echo",
Args: []string{"hello"},
Dir: "/tmp",
Env: []string{"FOO=bar"},
}
if input.Command != "echo" {
t.Errorf("expected command 'echo', got %q", input.Command)
}
if len(input.Args) != 1 || input.Args[0] != "hello" {
t.Errorf("expected args [hello], got %v", input.Args)
}
if input.Dir != "/tmp" {
t.Errorf("expected dir '/tmp', got %q", input.Dir)
}
if len(input.Env) != 1 {
t.Errorf("expected 1 env, got %d", len(input.Env))
}
}
// TestToolsProcess_ProcessRunOutput_Good exercises the process_run output DTO shape.
func TestToolsProcess_ProcessRunOutput_Good(t *testing.T) {
output := ProcessRunOutput{
ID: "proc-1",
ExitCode: 0,
Output: "hello\n",
Command: "echo",
}
if output.ID != "proc-1" {
t.Errorf("expected id 'proc-1', got %q", output.ID)
}
if output.ExitCode != 0 {
t.Errorf("expected exit code 0, got %d", output.ExitCode)
}
if output.Output != "hello\n" {
t.Errorf("expected output 'hello\\n', got %q", output.Output)
}
}
// TestToolsProcess_ProcessRun_Bad rejects calls without a process service.
func TestToolsProcess_ProcessRun_Bad(t *testing.T) {
svc, err := New(Options{})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.processRun(t.Context(), nil, ProcessRunInput{Command: "echo", Args: []string{"hi"}})
if err == nil {
t.Fatal("expected error when process service is unavailable")
}
}

View file

@ -83,6 +83,30 @@ type RAGCollectionsInput struct {
ShowStats bool `json:"show_stats,omitempty"` // true to include point counts and status
}
// RAGRetrieveInput contains parameters for retrieving chunks from a specific
// document source (rather than running a semantic query).
//
// input := RAGRetrieveInput{
// Source: "docs/services.md",
// Collection: "core-docs",
// Limit: 20,
// }
type RAGRetrieveInput struct {
Source string `json:"source"` // e.g. "docs/services.md"
Collection string `json:"collection,omitempty"` // e.g. "core-docs" (default: "hostuk-docs")
Limit int `json:"limit,omitempty"` // e.g. 20 (default: 50)
}
// RAGRetrieveOutput contains document chunks for a specific source.
//
// // len(out.Chunks) == 12, out.Source == "docs/services.md"
type RAGRetrieveOutput struct {
Source string `json:"source"` // e.g. "docs/services.md"
Collection string `json:"collection"` // collection searched
Chunks []RAGQueryResult `json:"chunks"` // chunks for the source, ordered by chunkIndex
Count int `json:"count"` // number of chunks returned
}
// CollectionInfo contains information about a Qdrant collection.
//
// // ci.Name == "core-docs", ci.PointsCount == 1500, ci.Status == "green"
@ -106,11 +130,28 @@ func (s *Service) registerRAGTools(server *mcp.Server) {
Description: "Query the RAG vector database for relevant documentation. Returns semantically similar content based on the query.",
}, s.ragQuery)
// rag_search is the spec-aligned alias for rag_query.
addToolRecorded(s, server, "rag", &mcp.Tool{
Name: "rag_search",
Description: "Semantic search across documents in the RAG vector database. Returns chunks ranked by similarity.",
}, s.ragQuery)
addToolRecorded(s, server, "rag", &mcp.Tool{
Name: "rag_ingest",
Description: "Ingest documents into the RAG vector database. Supports both single files and directories.",
}, s.ragIngest)
// rag_index is the spec-aligned alias for rag_ingest.
addToolRecorded(s, server, "rag", &mcp.Tool{
Name: "rag_index",
Description: "Index a document or directory into the RAG vector database.",
}, s.ragIngest)
addToolRecorded(s, server, "rag", &mcp.Tool{
Name: "rag_retrieve",
Description: "Retrieve chunks for a specific document source from the RAG vector database.",
}, s.ragRetrieve)
addToolRecorded(s, server, "rag", &mcp.Tool{
Name: "rag_collections",
Description: "List all available collections in the RAG vector database.",
@ -216,6 +257,86 @@ func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input
}, nil
}
// ragRetrieve handles the rag_retrieve tool call.
// Returns chunks for a specific source path by querying the collection with
// the source path as the query text and then filtering results down to the
// matching source. This preserves the transport abstraction that the rest of
// the RAG tools use while producing the document-scoped view callers expect.
func (s *Service) ragRetrieve(ctx context.Context, req *mcp.CallToolRequest, input RAGRetrieveInput) (*mcp.CallToolResult, RAGRetrieveOutput, error) {
collection := input.Collection
if collection == "" {
collection = DefaultRAGCollection
}
limit := input.Limit
if limit <= 0 {
limit = 50
}
s.logger.Info("MCP tool execution", "tool", "rag_retrieve", "source", input.Source, "collection", collection, "limit", limit, "user", log.Username())
if input.Source == "" {
return nil, RAGRetrieveOutput{}, log.E("ragRetrieve", "source cannot be empty", nil)
}
// Use the source path as the query text — semantically related chunks
// will rank highly, and we then keep only chunks whose Source matches.
// Over-fetch by an order of magnitude so document-level limits are met
// even when the source appears beyond the top-K of the raw query.
overfetch := limit * 10
if overfetch < 100 {
overfetch = 100
}
results, err := rag.QueryDocs(ctx, input.Source, collection, overfetch)
if err != nil {
log.Error("mcp: rag retrieve query failed", "source", input.Source, "collection", collection, "err", err)
return nil, RAGRetrieveOutput{}, log.E("ragRetrieve", "failed to retrieve chunks", err)
}
chunks := make([]RAGQueryResult, 0, limit)
for _, r := range results {
if r.Source != input.Source {
continue
}
chunks = append(chunks, RAGQueryResult{
Content: r.Text,
Source: r.Source,
Section: r.Section,
Category: r.Category,
ChunkIndex: r.ChunkIndex,
Score: r.Score,
})
if len(chunks) >= limit {
break
}
}
sortChunksByIndex(chunks)
return nil, RAGRetrieveOutput{
Source: input.Source,
Collection: collection,
Chunks: chunks,
Count: len(chunks),
}, nil
}
// sortChunksByIndex sorts chunks in ascending order of chunk index.
// Stable ordering keeps ties by their original position.
func sortChunksByIndex(chunks []RAGQueryResult) {
if len(chunks) <= 1 {
return
}
// Insertion sort keeps the code dependency-free and is fast enough
// for the small result sets rag_retrieve is designed for.
for i := 1; i < len(chunks); i++ {
j := i
for j > 0 && chunks[j-1].ChunkIndex > chunks[j].ChunkIndex {
chunks[j-1], chunks[j] = chunks[j], chunks[j-1]
j--
}
}
}
// ragCollections handles the rag_collections tool call.
func (s *Service) ragCollections(ctx context.Context, req *mcp.CallToolRequest, input RAGCollectionsInput) (*mcp.CallToolResult, RAGCollectionsOutput, error) {
s.logger.Info("MCP tool execution", "tool", "rag_collections", "show_stats", input.ShowStats, "user", log.Username())

View file

@ -171,3 +171,66 @@ func TestRAGCollectionsInput_ShowStats(t *testing.T) {
t.Error("Expected ShowStats to be true")
}
}
// TestToolsRag_RAGRetrieveInput_Good exercises the rag_retrieve DTO defaults.
func TestToolsRag_RAGRetrieveInput_Good(t *testing.T) {
input := RAGRetrieveInput{
Source: "docs/index.md",
Collection: "core-docs",
Limit: 20,
}
if input.Source != "docs/index.md" {
t.Errorf("expected source docs/index.md, got %q", input.Source)
}
if input.Limit != 20 {
t.Errorf("expected limit 20, got %d", input.Limit)
}
}
// TestToolsRag_RAGRetrieveOutput_Good exercises the rag_retrieve output shape.
func TestToolsRag_RAGRetrieveOutput_Good(t *testing.T) {
output := RAGRetrieveOutput{
Source: "docs/index.md",
Collection: "core-docs",
Chunks: []RAGQueryResult{
{Content: "first", ChunkIndex: 0},
{Content: "second", ChunkIndex: 1},
},
Count: 2,
}
if output.Count != 2 {
t.Fatalf("expected count 2, got %d", output.Count)
}
if output.Chunks[1].ChunkIndex != 1 {
t.Fatalf("expected chunk 1, got %d", output.Chunks[1].ChunkIndex)
}
}
// TestToolsRag_SortChunksByIndex_Good verifies sort orders by chunk index ascending.
func TestToolsRag_SortChunksByIndex_Good(t *testing.T) {
chunks := []RAGQueryResult{
{ChunkIndex: 3},
{ChunkIndex: 1},
{ChunkIndex: 2},
}
sortChunksByIndex(chunks)
for i, want := range []int{1, 2, 3} {
if chunks[i].ChunkIndex != want {
t.Fatalf("index %d: expected chunk %d, got %d", i, want, chunks[i].ChunkIndex)
}
}
}
// TestToolsRag_RagRetrieve_Bad rejects empty source paths.
func TestToolsRag_RagRetrieve_Bad(t *testing.T) {
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.ragRetrieve(t.Context(), nil, RAGRetrieveInput{})
if err == nil {
t.Fatal("expected error for empty source")
}
}

View file

@ -270,6 +270,18 @@ func (s *Service) registerWebviewTools(server *mcp.Server) {
Name: "webview_wait",
Description: "Wait for an element to appear by CSS selector.",
}, s.webviewWait)
// Embedded UI rendering — for pushing HTML/state to connected clients
// without requiring a Chrome DevTools connection.
addToolRecorded(s, server, "webview", &mcp.Tool{
Name: "webview_render",
Description: "Render HTML in an embedded webview by ID. Broadcasts to connected clients via the webview.render channel.",
}, s.webviewRender)
addToolRecorded(s, server, "webview", &mcp.Tool{
Name: "webview_update",
Description: "Update the HTML, title, or state of an embedded webview by ID. Broadcasts to connected clients via the webview.update channel.",
}, s.webviewUpdate)
}
// webviewConnect handles the webview_connect tool call.

View file

@ -0,0 +1,233 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"sync"
"time"
core "dappco.re/go/core"
"dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// WebviewRenderInput contains parameters for rendering an embedded
// HTML view. The named view is stored and broadcast so connected clients
// (Claude Code sessions, CoreGUI windows, HTTP/SSE subscribers) can
// display the content.
//
// input := WebviewRenderInput{
// ViewID: "dashboard",
// HTML: "<div id='app'>Loading...</div>",
// Title: "Agent Dashboard",
// Width: 1024,
// Height: 768,
// State: map[string]any{"theme": "dark"},
// }
type WebviewRenderInput struct {
ViewID string `json:"view_id"` // e.g. "dashboard"
HTML string `json:"html"` // rendered markup
Title string `json:"title,omitempty"` // e.g. "Agent Dashboard"
Width int `json:"width,omitempty"` // preferred width in pixels
Height int `json:"height,omitempty"` // preferred height in pixels
State map[string]any `json:"state,omitempty"` // initial view state
}
// WebviewRenderOutput reports the result of rendering an embedded view.
//
// // out.Success == true, out.ViewID == "dashboard"
type WebviewRenderOutput struct {
Success bool `json:"success"` // true when the view was stored and broadcast
ViewID string `json:"view_id"` // echoed view identifier
UpdatedAt time.Time `json:"updatedAt"` // when the view was rendered
}
// WebviewUpdateInput contains parameters for updating the state of an
// existing embedded view. Callers may provide HTML to replace the markup,
// patch fields in the view state, or do both.
//
// input := WebviewUpdateInput{
// ViewID: "dashboard",
// HTML: "<div id='app'>Ready</div>",
// State: map[string]any{"count": 42},
// Merge: true,
// }
type WebviewUpdateInput struct {
ViewID string `json:"view_id"` // e.g. "dashboard"
HTML string `json:"html,omitempty"` // replacement markup (optional)
Title string `json:"title,omitempty"` // e.g. "Agent Dashboard"
State map[string]any `json:"state,omitempty"` // partial state update
Merge bool `json:"merge,omitempty"` // merge state (default) or replace when false
}
// WebviewUpdateOutput reports the result of updating an embedded view.
//
// // out.Success == true, out.ViewID == "dashboard"
type WebviewUpdateOutput struct {
Success bool `json:"success"` // true when the view was updated and broadcast
ViewID string `json:"view_id"` // echoed view identifier
UpdatedAt time.Time `json:"updatedAt"` // when the view was last updated
}
// embeddedView captures the live state of a rendered UI view. Instances
// are kept per ViewID inside embeddedViewRegistry.
type embeddedView struct {
ViewID string
Title string
HTML string
Width int
Height int
State map[string]any
UpdatedAt time.Time
}
// embeddedViewRegistry stores the most recent render/update state for each
// view so new subscribers can pick up the current UI on connection.
// Operations are guarded by embeddedViewMu.
var (
embeddedViewMu sync.RWMutex
embeddedViewRegistry = map[string]*embeddedView{}
)
// ChannelWebviewRender is the channel used to broadcast webview_render events.
const ChannelWebviewRender = "webview.render"
// ChannelWebviewUpdate is the channel used to broadcast webview_update events.
const ChannelWebviewUpdate = "webview.update"
// webviewRender handles the webview_render tool call.
func (s *Service) webviewRender(ctx context.Context, req *mcp.CallToolRequest, input WebviewRenderInput) (*mcp.CallToolResult, WebviewRenderOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_render", "view", input.ViewID, "user", log.Username())
if core.Trim(input.ViewID) == "" {
return nil, WebviewRenderOutput{}, log.E("webviewRender", "view_id is required", nil)
}
now := time.Now()
view := &embeddedView{
ViewID: input.ViewID,
Title: input.Title,
HTML: input.HTML,
Width: input.Width,
Height: input.Height,
State: cloneStateMap(input.State),
UpdatedAt: now,
}
embeddedViewMu.Lock()
embeddedViewRegistry[input.ViewID] = view
embeddedViewMu.Unlock()
s.ChannelSend(ctx, ChannelWebviewRender, map[string]any{
"view_id": view.ViewID,
"title": view.Title,
"html": view.HTML,
"width": view.Width,
"height": view.Height,
"state": cloneStateMap(view.State),
"updatedAt": view.UpdatedAt,
})
return nil, WebviewRenderOutput{
Success: true,
ViewID: view.ViewID,
UpdatedAt: view.UpdatedAt,
}, nil
}
// webviewUpdate handles the webview_update tool call.
func (s *Service) webviewUpdate(ctx context.Context, req *mcp.CallToolRequest, input WebviewUpdateInput) (*mcp.CallToolResult, WebviewUpdateOutput, error) {
s.logger.Info("MCP tool execution", "tool", "webview_update", "view", input.ViewID, "user", log.Username())
if core.Trim(input.ViewID) == "" {
return nil, WebviewUpdateOutput{}, log.E("webviewUpdate", "view_id is required", nil)
}
now := time.Now()
embeddedViewMu.Lock()
view, ok := embeddedViewRegistry[input.ViewID]
if !ok {
// Updating a view that was never rendered creates one lazily so
// clients that reconnect mid-session get a consistent snapshot.
view = &embeddedView{ViewID: input.ViewID, State: map[string]any{}}
embeddedViewRegistry[input.ViewID] = view
}
if input.HTML != "" {
view.HTML = input.HTML
}
if input.Title != "" {
view.Title = input.Title
}
if input.State != nil {
merge := input.Merge || len(view.State) == 0
if merge {
if view.State == nil {
view.State = map[string]any{}
}
for k, v := range input.State {
view.State[k] = v
}
} else {
view.State = cloneStateMap(input.State)
}
}
view.UpdatedAt = now
snapshot := *view
snapshot.State = cloneStateMap(view.State)
embeddedViewMu.Unlock()
s.ChannelSend(ctx, ChannelWebviewUpdate, map[string]any{
"view_id": snapshot.ViewID,
"title": snapshot.Title,
"html": snapshot.HTML,
"width": snapshot.Width,
"height": snapshot.Height,
"state": snapshot.State,
"updatedAt": snapshot.UpdatedAt,
})
return nil, WebviewUpdateOutput{
Success: true,
ViewID: snapshot.ViewID,
UpdatedAt: snapshot.UpdatedAt,
}, nil
}
// cloneStateMap returns a shallow copy of a state map.
//
// cloned := cloneStateMap(map[string]any{"a": 1}) // cloned["a"] == 1
func cloneStateMap(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// lookupEmbeddedView returns the current snapshot of an embedded view, if any.
//
// view, ok := lookupEmbeddedView("dashboard")
func lookupEmbeddedView(id string) (*embeddedView, bool) {
embeddedViewMu.RLock()
defer embeddedViewMu.RUnlock()
view, ok := embeddedViewRegistry[id]
if !ok {
return nil, false
}
snapshot := *view
snapshot.State = cloneStateMap(view.State)
return &snapshot, true
}
// resetEmbeddedViews clears the registry. Intended for tests.
func resetEmbeddedViews() {
embeddedViewMu.Lock()
defer embeddedViewMu.Unlock()
embeddedViewRegistry = map[string]*embeddedView{}
}

View file

@ -0,0 +1,137 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"testing"
)
// TestToolsWebviewEmbed_WebviewRender_Good registers a view and verifies the
// registry keeps the rendered HTML and state.
func TestToolsWebviewEmbed_WebviewRender_Good(t *testing.T) {
t.Cleanup(resetEmbeddedViews)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, out, err := svc.webviewRender(context.Background(), nil, WebviewRenderInput{
ViewID: "dashboard",
HTML: "<p>hello</p>",
Title: "Demo",
State: map[string]any{"count": 1},
})
if err != nil {
t.Fatalf("webviewRender returned error: %v", err)
}
if !out.Success {
t.Fatal("expected Success=true")
}
if out.ViewID != "dashboard" {
t.Fatalf("expected view id 'dashboard', got %q", out.ViewID)
}
if out.UpdatedAt.IsZero() {
t.Fatal("expected non-zero UpdatedAt")
}
view, ok := lookupEmbeddedView("dashboard")
if !ok {
t.Fatal("expected view to be stored in registry")
}
if view.HTML != "<p>hello</p>" {
t.Fatalf("expected HTML '<p>hello</p>', got %q", view.HTML)
}
if view.State["count"] != 1 {
t.Fatalf("expected state.count=1, got %v", view.State["count"])
}
}
// TestToolsWebviewEmbed_WebviewRender_Bad ensures empty view IDs are rejected.
func TestToolsWebviewEmbed_WebviewRender_Bad(t *testing.T) {
t.Cleanup(resetEmbeddedViews)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.webviewRender(context.Background(), nil, WebviewRenderInput{})
if err == nil {
t.Fatal("expected error for empty view_id")
}
}
// TestToolsWebviewEmbed_WebviewUpdate_Good merges a state patch into the
// previously rendered view.
func TestToolsWebviewEmbed_WebviewUpdate_Good(t *testing.T) {
t.Cleanup(resetEmbeddedViews)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.webviewRender(context.Background(), nil, WebviewRenderInput{
ViewID: "dashboard",
HTML: "<p>hello</p>",
State: map[string]any{"count": 1},
})
if err != nil {
t.Fatalf("seed render failed: %v", err)
}
_, out, err := svc.webviewUpdate(context.Background(), nil, WebviewUpdateInput{
ViewID: "dashboard",
State: map[string]any{"theme": "dark"},
Merge: true,
})
if err != nil {
t.Fatalf("webviewUpdate returned error: %v", err)
}
if !out.Success {
t.Fatal("expected Success=true")
}
view, ok := lookupEmbeddedView("dashboard")
if !ok {
t.Fatal("expected view to exist after update")
}
if view.State["count"] != 1 {
t.Fatalf("expected count to persist after merge, got %v", view.State["count"])
}
if view.State["theme"] != "dark" {
t.Fatalf("expected theme 'dark' after merge, got %v", view.State["theme"])
}
}
// TestToolsWebviewEmbed_WebviewUpdate_Ugly updates a view that was never
// rendered and verifies a fresh registry entry is created.
func TestToolsWebviewEmbed_WebviewUpdate_Ugly(t *testing.T) {
t.Cleanup(resetEmbeddedViews)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, out, err := svc.webviewUpdate(context.Background(), nil, WebviewUpdateInput{
ViewID: "ghost",
HTML: "<p>new</p>",
})
if err != nil {
t.Fatalf("webviewUpdate returned error: %v", err)
}
if !out.Success {
t.Fatal("expected Success=true for lazy-create update")
}
view, ok := lookupEmbeddedView("ghost")
if !ok {
t.Fatal("expected ghost view to be created lazily")
}
if view.HTML != "<p>new</p>" {
t.Fatalf("expected HTML '<p>new</p>', got %q", view.HTML)
}
}

264
pkg/mcp/tools_ws_client.go Normal file
View file

@ -0,0 +1,264 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
core "dappco.re/go/core"
"dappco.re/go/core/log"
"github.com/gorilla/websocket"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// WSConnectInput contains parameters for opening an outbound WebSocket
// connection from the MCP server. Each connection is given a stable ID that
// subsequent ws_send and ws_close calls use to address it.
//
// input := WSConnectInput{URL: "wss://example.com/ws", Timeout: 10}
type WSConnectInput struct {
URL string `json:"url"` // e.g. "wss://example.com/ws"
Headers map[string]string `json:"headers,omitempty"` // custom request headers
Timeout int `json:"timeout,omitempty"` // handshake timeout in seconds (default: 30)
}
// WSConnectOutput contains the result of opening a WebSocket connection.
//
// // out.Success == true, out.ID == "ws-0af3…"
type WSConnectOutput struct {
Success bool `json:"success"` // true when the handshake completed
ID string `json:"id"` // e.g. "ws-0af3…"
URL string `json:"url"` // the URL that was dialled
}
// WSSendInput contains parameters for sending a message on an open
// WebSocket connection.
//
// input := WSSendInput{ID: "ws-0af3…", Message: "ping"}
type WSSendInput struct {
ID string `json:"id"` // e.g. "ws-0af3…"
Message string `json:"message"` // payload to send
Binary bool `json:"binary,omitempty"` // true to send a binary frame (payload is base64 text)
}
// WSSendOutput contains the result of sending a message.
//
// // out.Success == true, out.ID == "ws-0af3…"
type WSSendOutput struct {
Success bool `json:"success"` // true when the message was written
ID string `json:"id"` // e.g. "ws-0af3…"
Bytes int `json:"bytes"` // number of bytes written
}
// WSCloseInput contains parameters for closing a WebSocket connection.
//
// input := WSCloseInput{ID: "ws-0af3…", Reason: "done"}
type WSCloseInput struct {
ID string `json:"id"` // e.g. "ws-0af3…"
Code int `json:"code,omitempty"` // close code (default: 1000 - normal closure)
Reason string `json:"reason,omitempty"` // human-readable reason
}
// WSCloseOutput contains the result of closing a WebSocket connection.
//
// // out.Success == true, out.ID == "ws-0af3…"
type WSCloseOutput struct {
Success bool `json:"success"` // true when the connection was closed
ID string `json:"id"` // e.g. "ws-0af3…"
Message string `json:"message,omitempty"` // e.g. "connection closed"
}
// wsClientConn tracks an outbound WebSocket connection tied to a stable ID.
type wsClientConn struct {
ID string
URL string
conn *websocket.Conn
writeMu sync.Mutex
CreatedAt time.Time
}
// wsClientRegistry holds all live outbound WebSocket connections keyed by ID.
// Access is guarded by wsClientMu.
var (
wsClientMu sync.Mutex
wsClientRegistry = map[string]*wsClientConn{}
)
// registerWSClientTools registers the outbound WebSocket client tools.
func (s *Service) registerWSClientTools(server *mcp.Server) {
addToolRecorded(s, server, "ws", &mcp.Tool{
Name: "ws_connect",
Description: "Open an outbound WebSocket connection. Returns a connection ID for subsequent ws_send and ws_close calls.",
}, s.wsConnect)
addToolRecorded(s, server, "ws", &mcp.Tool{
Name: "ws_send",
Description: "Send a text or binary message on an open WebSocket connection identified by ID.",
}, s.wsSend)
addToolRecorded(s, server, "ws", &mcp.Tool{
Name: "ws_close",
Description: "Close an open WebSocket connection identified by ID.",
}, s.wsClose)
}
// wsConnect handles the ws_connect tool call.
func (s *Service) wsConnect(ctx context.Context, req *mcp.CallToolRequest, input WSConnectInput) (*mcp.CallToolResult, WSConnectOutput, error) {
s.logger.Security("MCP tool execution", "tool", "ws_connect", "url", input.URL, "user", log.Username())
if core.Trim(input.URL) == "" {
return nil, WSConnectOutput{}, log.E("wsConnect", "url is required", nil)
}
timeout := time.Duration(input.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
dialer := websocket.Dialer{
HandshakeTimeout: timeout,
}
headers := http.Header{}
for k, v := range input.Headers {
headers.Set(k, v)
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, _, err := dialer.DialContext(dialCtx, input.URL, headers)
if err != nil {
log.Error("mcp: ws connect failed", "url", input.URL, "err", err)
return nil, WSConnectOutput{}, log.E("wsConnect", "failed to connect", err)
}
id := newWSClientID()
client := &wsClientConn{
ID: id,
URL: input.URL,
conn: conn,
CreatedAt: time.Now(),
}
wsClientMu.Lock()
wsClientRegistry[id] = client
wsClientMu.Unlock()
return nil, WSConnectOutput{
Success: true,
ID: id,
URL: input.URL,
}, nil
}
// wsSend handles the ws_send tool call.
func (s *Service) wsSend(ctx context.Context, req *mcp.CallToolRequest, input WSSendInput) (*mcp.CallToolResult, WSSendOutput, error) {
s.logger.Info("MCP tool execution", "tool", "ws_send", "id", input.ID, "binary", input.Binary, "user", log.Username())
if core.Trim(input.ID) == "" {
return nil, WSSendOutput{}, log.E("wsSend", "id is required", nil)
}
client, ok := getWSClient(input.ID)
if !ok {
return nil, WSSendOutput{}, log.E("wsSend", "connection not found", nil)
}
messageType := websocket.TextMessage
if input.Binary {
messageType = websocket.BinaryMessage
}
client.writeMu.Lock()
err := client.conn.WriteMessage(messageType, []byte(input.Message))
client.writeMu.Unlock()
if err != nil {
log.Error("mcp: ws send failed", "id", input.ID, "err", err)
return nil, WSSendOutput{}, log.E("wsSend", "failed to send message", err)
}
return nil, WSSendOutput{
Success: true,
ID: input.ID,
Bytes: len(input.Message),
}, nil
}
// wsClose handles the ws_close tool call.
func (s *Service) wsClose(ctx context.Context, req *mcp.CallToolRequest, input WSCloseInput) (*mcp.CallToolResult, WSCloseOutput, error) {
s.logger.Info("MCP tool execution", "tool", "ws_close", "id", input.ID, "user", log.Username())
if core.Trim(input.ID) == "" {
return nil, WSCloseOutput{}, log.E("wsClose", "id is required", nil)
}
wsClientMu.Lock()
client, ok := wsClientRegistry[input.ID]
if ok {
delete(wsClientRegistry, input.ID)
}
wsClientMu.Unlock()
if !ok {
return nil, WSCloseOutput{}, log.E("wsClose", "connection not found", nil)
}
code := input.Code
if code == 0 {
code = websocket.CloseNormalClosure
}
reason := input.Reason
if reason == "" {
reason = "closed"
}
client.writeMu.Lock()
_ = client.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(code, reason),
time.Now().Add(5*time.Second),
)
client.writeMu.Unlock()
_ = client.conn.Close()
return nil, WSCloseOutput{
Success: true,
ID: input.ID,
Message: "connection closed",
}, nil
}
// newWSClientID returns a fresh identifier for an outbound WebSocket client.
//
// id := newWSClientID() // "ws-0af3…"
func newWSClientID() string {
var buf [8]byte
_, _ = rand.Read(buf[:])
return "ws-" + hex.EncodeToString(buf[:])
}
// getWSClient returns a tracked outbound WebSocket client by ID, if any.
//
// client, ok := getWSClient("ws-0af3…")
func getWSClient(id string) (*wsClientConn, bool) {
wsClientMu.Lock()
defer wsClientMu.Unlock()
client, ok := wsClientRegistry[id]
return client, ok
}
// resetWSClients drops all tracked outbound WebSocket clients. Intended for tests.
func resetWSClients() {
wsClientMu.Lock()
defer wsClientMu.Unlock()
for id, client := range wsClientRegistry {
_ = client.conn.Close()
delete(wsClientRegistry, id)
}
}

View file

@ -0,0 +1,169 @@
// SPDX-License-Identifier: EUPL-1.2
package mcp
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// TestToolsWSClient_WSConnect_Good dials a test WebSocket server and verifies
// the handshake completes and a client ID is assigned.
func TestToolsWSClient_WSConnect_Good(t *testing.T) {
t.Cleanup(resetWSClients)
server := startTestWSServer(t)
defer server.Close()
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, out, err := svc.wsConnect(context.Background(), nil, WSConnectInput{
URL: "ws" + strings.TrimPrefix(server.URL, "http") + "/ws",
Timeout: 5,
})
if err != nil {
t.Fatalf("wsConnect failed: %v", err)
}
if !out.Success {
t.Fatal("expected Success=true")
}
if !strings.HasPrefix(out.ID, "ws-") {
t.Fatalf("expected ID prefix 'ws-', got %q", out.ID)
}
_, _, err = svc.wsClose(context.Background(), nil, WSCloseInput{ID: out.ID})
if err != nil {
t.Fatalf("wsClose failed: %v", err)
}
}
// TestToolsWSClient_WSConnect_Bad rejects empty URLs.
func TestToolsWSClient_WSConnect_Bad(t *testing.T) {
t.Cleanup(resetWSClients)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.wsConnect(context.Background(), nil, WSConnectInput{})
if err == nil {
t.Fatal("expected error for empty URL")
}
}
// TestToolsWSClient_WSSendClose_Good sends a message on an open connection
// and then closes it.
func TestToolsWSClient_WSSendClose_Good(t *testing.T) {
t.Cleanup(resetWSClients)
server := startTestWSServer(t)
defer server.Close()
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, conn, err := svc.wsConnect(context.Background(), nil, WSConnectInput{
URL: "ws" + strings.TrimPrefix(server.URL, "http") + "/ws",
Timeout: 5,
})
if err != nil {
t.Fatalf("wsConnect failed: %v", err)
}
_, sendOut, err := svc.wsSend(context.Background(), nil, WSSendInput{
ID: conn.ID,
Message: "ping",
})
if err != nil {
t.Fatalf("wsSend failed: %v", err)
}
if !sendOut.Success {
t.Fatal("expected Success=true for wsSend")
}
if sendOut.Bytes != 4 {
t.Fatalf("expected 4 bytes written, got %d", sendOut.Bytes)
}
_, closeOut, err := svc.wsClose(context.Background(), nil, WSCloseInput{ID: conn.ID})
if err != nil {
t.Fatalf("wsClose failed: %v", err)
}
if !closeOut.Success {
t.Fatal("expected Success=true for wsClose")
}
if _, ok := getWSClient(conn.ID); ok {
t.Fatal("expected connection to be removed after close")
}
}
// TestToolsWSClient_WSSend_Bad rejects unknown connection IDs.
func TestToolsWSClient_WSSend_Bad(t *testing.T) {
t.Cleanup(resetWSClients)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.wsSend(context.Background(), nil, WSSendInput{ID: "ws-missing", Message: "x"})
if err == nil {
t.Fatal("expected error for unknown connection ID")
}
}
// TestToolsWSClient_WSClose_Bad rejects closes for unknown connection IDs.
func TestToolsWSClient_WSClose_Bad(t *testing.T) {
t.Cleanup(resetWSClients)
svc, err := New(Options{WorkspaceRoot: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, _, err = svc.wsClose(context.Background(), nil, WSCloseInput{ID: "ws-missing"})
if err == nil {
t.Fatal("expected error for unknown connection ID")
}
}
// startTestWSServer returns an httptest.Server running a minimal echo WebSocket
// handler used by the ws_connect/ws_send tests.
func startTestWSServer(t *testing.T) *httptest.Server {
t.Helper()
upgrader := websocket.Upgrader{
CheckOrigin: func(*http.Request) bool { return true },
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
})
return httptest.NewServer(mux)
}

View file

@ -249,10 +249,31 @@ func handleMCPDiscovery(w http.ResponseWriter, r *http.Request) {
"command": "core-agent",
"args": []string{"mcp"},
},
Capabilities: []string{"tools", "resources"},
UseWhen: []string{"Need to dispatch work to Codex/Claude/Gemini", "Need workspace status", "Need semantic search"},
Capabilities: []string{"tools", "resources"},
UseWhen: []string{
"Need to dispatch work to Codex/Claude/Gemini",
"Need workspace status",
"Need semantic search",
},
RelatedServers: []string{"core-mcp"},
},
{
ID: "core-mcp",
Name: "Core MCP",
Description: "File ops, process and build tools, RAG search, webview, dashboards — the agent-facing MCP framework.",
Connection: map[string]any{
"type": "stdio",
"command": "core-mcp",
},
Capabilities: []string{"tools", "resources", "logging"},
UseWhen: []string{
"Need to read/write files inside a workspace",
"Need to start or monitor processes",
"Need to run RAG queries or index documents",
"Need to render or update an embedded dashboard view",
},
RelatedServers: []string{"core-agent"},
},
},
}