From 391e007bc2fa2c60987bd4275109c7339beb47f7 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 24 Aug 2026 18:54:08 +0000 Subject: [PATCH] feat(mcp): serve agent management over MCP LocalAGI speaks MCP as a client, so an agent can use external tools, but nothing outside the process can create or configure an agent except the REST API and the Web UI that sits on it. The web server now also answers MCP at /mcp, with eight tools over the same agent pool the REST handlers use: list, read, create, update, delete, pause, start, and a schema tool that reports the connectors, actions, dynamic prompts and filters the instance provides. The endpoint mounts after the API key middleware, so MCP clients authenticate with the bearer token that already guards the REST API. It runs stateless and replies with JSON rather than an event stream, because each call is one request and one response, which is what survives the fasthttp adaptor. The create and update schemas come from state.AgentConfig by reflection, so they follow the struct as it grows. Reflection marks every field without omitempty as required, which would force a client to send all 58 of them, so both tools require only the agent name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011wnxzYVrUZ7xtpqAGHM474 --- README.md | 41 ++++++ go.mod | 2 +- webui/mcp.go | 296 ++++++++++++++++++++++++++++++++++++++ webui/mcp_test.go | 287 ++++++++++++++++++++++++++++++++++++ webui/routes.go | 3 + webui/webui_suite_test.go | 13 ++ 6 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 webui/mcp.go create mode 100644 webui/mcp_test.go create mode 100644 webui/webui_suite_test.go diff --git a/README.md b/README.md index 22016d7..e7f1a63 100644 --- a/README.md +++ b/README.md @@ -696,6 +696,47 @@ You can create MCP servers in any language that supports the MCP protocol and ad 1. **Via Web UI**: In the MCP Settings section of agent creation, add MCP servers 2. **Via API**: Include MCP server configuration in your agent config +#### LocalAGI as an MCP Server + +LocalAGI also works the other way around: it exposes its own MCP server so that MCP clients can manage agents. The endpoint is served at `/mcp` on the same address as the Web UI and the REST API: + +``` +http://localhost:3000/mcp +``` + +It speaks Streamable HTTP and is protected by the same API keys as the rest of the API, so clients authenticate with `Authorization: Bearer ` when `LOCALAGI_API_KEYS` is set. + +Example client configuration: + +```json +{ + "mcpServers": { + "localagi": { + "type": "http", + "url": "http://localhost:3000/mcp", + "headers": { + "Authorization": "Bearer your-api-key" + } + } + } +} +``` + +The following tools are available: + +| Tool | Description | +|------|-------------| +| `list_agents` | List the configured agents, with their model and current state | +| `get_agent_config` | Read the full configuration of an agent | +| `create_agent` | Create a new agent and start it (only `name` is required) | +| `update_agent_config` | Replace the configuration of an agent and restart it | +| `delete_agent` | Delete an agent and its state | +| `pause_agent` | Pause a running agent | +| `start_agent` | Resume a paused agent | +| `get_agent_config_schema` | Describe the configuration fields, and the connectors, actions, dynamic prompts and filters available on this instance | + +`create_agent` and `update_agent_config` accept the same configuration as the REST API. Call `get_agent_config_schema` first to discover which connectors, actions and filters the instance provides, and what each one expects. + #### Best Practices - **Security**: Always validate inputs and use proper authentication for remote MCP servers diff --git a/go.mod b/go.mod index 8747469..9a38176 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.11 github.com/gofiber/template/html/v2 v2.1.3 github.com/google/go-github/v69 v69.2.0 + github.com/google/jsonschema-go v0.3.0 github.com/google/uuid v1.6.0 github.com/jung-kurt/gofpdf v1.16.2 github.com/modelcontextprotocol/go-sdk v1.2.0 @@ -73,7 +74,6 @@ require ( github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/webui/mcp.go b/webui/mcp.go new file mode 100644 index 0000000..5586178 --- /dev/null +++ b/webui/mcp.go @@ -0,0 +1,296 @@ +package webui + +import ( + "context" + "fmt" + "net/http" + + fiber "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mudler/LocalAGI/core/agent" + "github.com/mudler/LocalAGI/core/state" + "github.com/mudler/LocalAGI/pkg/config" + "github.com/mudler/LocalAGI/services" +) + +// mcpServerName identifies LocalAGI to MCP clients. +const mcpServerName = "LocalAGI" + +// agentNameArgs is the input of every tool addressing a single agent. +type agentNameArgs struct { + Name string `json:"name" jsonschema:"name of the agent"` +} + +// statusResult is returned by tools that only report success. +type statusResult struct { + Status string `json:"status"` +} + +// agentSummary is one entry of list_agents. +type agentSummary struct { + Name string `json:"name"` + Description string `json:"description"` + Model string `json:"model" jsonschema:"model configured for this agent, empty when it uses the instance default"` + Paused bool `json:"paused"` + Running bool `json:"running" jsonschema:"whether the agent is loaded in the pool; a stopped instance still keeps its configuration"` +} + +type listAgentsResult struct { + Agents []agentSummary `json:"agents"` +} + +// updateAgentArgs targets an agent by name and replaces its configuration. +type updateAgentArgs struct { + Name string `json:"name" jsonschema:"name of the agent to update"` + Config state.AgentConfig `json:"config" jsonschema:"the new configuration, replacing the current one entirely"` +} + +type emptyArgs struct{} + +// registerMCPRoutes mounts the MCP endpoint. It is registered after the API key +// middleware, so MCP clients authenticate with the same bearer token as the +// REST API. +// +// The handler runs stateless and answers with application/json rather than an +// event stream: every call is a self-contained request/response, which is what +// the agent management tools need and what survives the fasthttp adaptor. +func (a *App) registerMCPRoutes(pool *state.AgentPool, webapp *fiber.App) { + srv := a.newMCPServer(pool) + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}, + ) + webapp.All("/mcp", adaptor.HTTPHandler(handler)) +} + +// newMCPServer builds the MCP server exposing agent management over the +// Model Context Protocol. The tools mirror the agent REST API. +func (a *App) newMCPServer(pool *state.AgentPool) *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{Name: mcpServerName, Version: "v1"}, nil) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_agents", + Description: "List the agents configured in LocalAGI, with their model and current state.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args emptyArgs) (*mcp.CallToolResult, listAgentsResult, error) { + result := listAgentsResult{Agents: []agentSummary{}} + for _, name := range pool.AllAgents() { + summary := agentSummary{Name: name} + if cfg := pool.GetConfig(name); cfg != nil { + summary.Description = cfg.Description + summary.Model = cfg.Model + } + if agent := pool.GetAgent(name); agent != nil { + summary.Running = true + summary.Paused = agent.Paused() + } + result.Agents = append(result.Agents, summary) + } + return nil, result, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_agent_config", + Description: "Get the full configuration of an agent.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args agentNameArgs) (*mcp.CallToolResult, state.AgentConfig, error) { + cfg := pool.GetConfig(args.Name) + if cfg == nil { + return nil, state.AgentConfig{}, errAgentNotFound(args.Name) + } + return nil, normalizeAgentConfig(*cfg), nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "create_agent", + Description: "Create a new agent and start it. Only the name is required; every other field falls back to the instance default. Call get_agent_config_schema first to discover the available connectors, actions, dynamic prompts and filters.", + InputSchema: agentConfigSchema("name"), + }, func(ctx context.Context, req *mcp.CallToolRequest, args state.AgentConfig) (*mcp.CallToolResult, statusResult, error) { + if args.Name == "" { + return nil, statusResult{}, fmt.Errorf("name is required") + } + if err := pool.CreateAgent(args.Name, &args); err != nil { + return nil, statusResult{}, err + } + return nil, statusResult{Status: "ok"}, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "update_agent_config", + Description: "Replace the configuration of an existing agent and restart it. The configuration replaces the current one in full, so read it with get_agent_config first and send it back with your changes applied.", + InputSchema: updateAgentSchema(), + }, func(ctx context.Context, req *mcp.CallToolRequest, args updateAgentArgs) (*mcp.CallToolResult, statusResult, error) { + if pool.GetConfig(args.Name) == nil { + return nil, statusResult{}, errAgentNotFound(args.Name) + } + if err := pool.RecreateAgent(args.Name, &args.Config); err != nil { + return nil, statusResult{}, err + } + return nil, statusResult{Status: "ok"}, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "delete_agent", + Description: "Delete an agent and remove it from the pool. This also discards its state and character files.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args agentNameArgs) (*mcp.CallToolResult, statusResult, error) { + if pool.GetConfig(args.Name) == nil { + return nil, statusResult{}, errAgentNotFound(args.Name) + } + if err := pool.Remove(args.Name); err != nil { + return nil, statusResult{}, err + } + return nil, statusResult{Status: "ok"}, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "pause_agent", + Description: "Pause a running agent. It keeps its configuration and stops processing jobs until it is started again.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args agentNameArgs) (*mcp.CallToolResult, statusResult, error) { + agent := pool.GetAgent(args.Name) + if agent == nil { + return nil, statusResult{}, errAgentNotFound(args.Name) + } + agent.Pause() + return nil, statusResult{Status: "ok"}, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "start_agent", + Description: "Resume a paused agent.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args agentNameArgs) (*mcp.CallToolResult, statusResult, error) { + agent := pool.GetAgent(args.Name) + if agent == nil { + return nil, statusResult{}, errAgentNotFound(args.Name) + } + agent.Resume() + return nil, statusResult{Status: "ok"}, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_agent_config_schema", + Description: "Describe the agent configuration fields, and the connectors, actions, dynamic prompts and filters available on this LocalAGI instance.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args emptyArgs) (*mcp.CallToolResult, state.AgentConfigMeta, error) { + meta := state.NewAgentConfigMeta( + services.ActionsConfigMeta(a.config.CustomActionsDir), + services.ConnectorsConfigMeta(), + services.DynamicPromptsConfigMeta(a.config.CustomActionsDir), + services.FiltersConfigMeta(), + ) + return nil, normalizeConfigMeta(meta), nil + }) + + return srv +} + +// normalizeConfigMeta replaces nil slices with empty ones. The MCP SDK +// validates tool output against the schema reflected from the returned type, +// where a nil slice marshals to null and fails the "array" check. +func normalizeConfigMeta(meta state.AgentConfigMeta) state.AgentConfigMeta { + meta.Fields = normalizeFields(meta.Fields) + meta.MCPServers = normalizeFields(meta.MCPServers) + meta.Filters = normalizeFieldGroups(meta.Filters) + meta.Connectors = normalizeFieldGroups(meta.Connectors) + meta.Actions = normalizeFieldGroups(meta.Actions) + meta.DynamicPrompts = normalizeFieldGroups(meta.DynamicPrompts) + return meta +} + +func normalizeFields(fields []config.Field) []config.Field { + if fields == nil { + return []config.Field{} + } + return fields +} + +func normalizeFieldGroups(groups []config.FieldGroup) []config.FieldGroup { + out := make([]config.FieldGroup, 0, len(groups)) + for _, g := range groups { + g.Fields = normalizeFields(g.Fields) + out = append(out, g) + } + return out +} + +// mustSchemaFor reflects the JSON schema of T, or panics. The schemas are built +// once at server construction, so a failure here is a programming error. +func mustSchemaFor[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(fmt.Sprintf("reflecting JSON schema: %v", err)) + } + return schema +} + +// agentConfigSchema is the reflected schema of state.AgentConfig with only the +// listed properties required. Reflection marks every field without omitempty as +// required, which would force a client to send the whole configuration; every +// field other than the name is in fact optional. +func agentConfigSchema(required ...string) *jsonschema.Schema { + schema := mustSchemaFor[state.AgentConfig]() + schema.Required = required + return schema +} + +// updateAgentSchema describes update_agent_config: the target agent name, plus +// a full replacement configuration whose fields are all optional. +func updateAgentSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "name of the agent to update", + }, + "config": withDescription( + agentConfigSchema(), + "the new configuration, replacing the current one entirely", + ), + }, + Required: []string{"name", "config"}, + } +} + +func withDescription(schema *jsonschema.Schema, description string) *jsonschema.Schema { + schema.Description = description + return schema +} + +// normalizeAgentConfig replaces nil slices with empty ones so the configuration +// validates against its own reflected schema, where a nil slice marshals to +// null instead of an array. +func normalizeAgentConfig(cfg state.AgentConfig) state.AgentConfig { + if cfg.Connector == nil { + cfg.Connector = []state.ConnectorConfig{} + } + if cfg.Actions == nil { + cfg.Actions = []state.ActionsConfig{} + } + if cfg.DynamicPrompts == nil { + cfg.DynamicPrompts = []state.DynamicPromptsConfig{} + } + if cfg.Filters == nil { + cfg.Filters = []state.FiltersConfig{} + } + if cfg.MCPServers == nil { + cfg.MCPServers = []agent.MCPServer{} + } + if cfg.MCPSTDIOServers == nil { + cfg.MCPSTDIOServers = []agent.MCPSTDIOServer{} + } + for i, srv := range cfg.MCPSTDIOServers { + if srv.Args == nil { + cfg.MCPSTDIOServers[i].Args = []string{} + } + if srv.Env == nil { + cfg.MCPSTDIOServers[i].Env = []string{} + } + } + return cfg +} + +// errAgentNotFound is returned to the client as a tool error, so the model can +// see it and correct itself. +func errAgentNotFound(name string) error { + return fmt.Errorf("agent %q not found", name) +} diff --git a/webui/mcp_test.go b/webui/mcp_test.go new file mode 100644 index 0000000..1934887 --- /dev/null +++ b/webui/mcp_test.go @@ -0,0 +1,287 @@ +package webui + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + + fiber "github.com/gofiber/fiber/v2" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mudler/LocalAGI/core/state" + "github.com/mudler/LocalAGI/services" + "github.com/mudler/LocalAGI/services/skills" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// newTestPool builds an AgentPool rooted in a throwaway directory, wired with +// the same action/connector/prompt/filter providers the server uses. +func newTestPool(dir string) *state.AgentPool { + skillsService, err := skills.NewService(dir) + Expect(err).ToNot(HaveOccurred()) + + pool, err := state.NewAgentPool( + "test-model", "", "", "", "", + "http://127.0.0.1:1/v1", "", + dir, + services.Actions(map[string]string{services.ConfigStateDir: dir}), + services.Connectors, + services.DynamicPrompts(map[string]string{services.ConfigStateDir: dir}), + services.Filters, + "5m", + false, + skillsService, + ) + Expect(err).ToNot(HaveOccurred()) + return pool +} + +// connectMCP runs the app's MCP server over an in-memory transport and returns +// a connected client session. +func connectMCP(ctx context.Context, pool *state.AgentPool) *mcp.ClientSession { + app := &App{config: NewConfig(WithPool(pool))} + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + srv := app.newMCPServer(pool) + go func() { + defer GinkgoRecover() + _ = srv.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + Expect(err).ToNot(HaveOccurred()) + return session +} + +// callTool invokes a tool and returns the raw result. +func callTool(ctx context.Context, session *mcp.ClientSession, name string, args any) *mcp.CallToolResult { + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + Expect(err).ToNot(HaveOccurred()) + return res +} + +// callToolOK invokes a tool, asserts it succeeded, and decodes its structured +// output into out. +func callToolOK(ctx context.Context, session *mcp.ClientSession, name string, args any, out any) { + res := callTool(ctx, session, name, args) + Expect(res.IsError).To(BeFalse(), "tool %s failed: %s", name, textOf(res)) + if out == nil { + return + } + data, err := json.Marshal(res.StructuredContent) + Expect(err).ToNot(HaveOccurred()) + Expect(json.Unmarshal(data, out)).To(Succeed()) +} + +// textOf concatenates the textual content of a tool result. +func textOf(res *mcp.CallToolResult) string { + out := "" + for _, c := range res.Content { + if t, ok := c.(*mcp.TextContent); ok { + out += t.Text + } + } + return out +} + +var _ = Describe("MCP server", func() { + var ( + ctx context.Context + cancel context.CancelFunc + session *mcp.ClientSession + ) + + BeforeEach(func() { + ctx, cancel = context.WithCancel(context.Background()) + session = connectMCP(ctx, newTestPool(GinkgoT().TempDir())) + }) + + AfterEach(func() { + session.Close() + cancel() + }) + + It("exposes the agent management tools", func() { + tools, err := session.ListTools(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + + names := []string{} + for _, t := range tools.Tools { + names = append(names, t.Name) + } + + Expect(names).To(ConsistOf( + "list_agents", + "get_agent_config", + "create_agent", + "update_agent_config", + "delete_agent", + "pause_agent", + "start_agent", + "get_agent_config_schema", + )) + }) + It("describes the agent configuration schema", func() { + meta := state.AgentConfigMeta{} + callToolOK(ctx, session, "get_agent_config_schema", struct{}{}, &meta) + + fieldNames := []string{} + for _, f := range meta.Fields { + fieldNames = append(fieldNames, f.Name) + } + Expect(fieldNames).To(ContainElements("name", "model", "system_prompt")) + Expect(meta.Actions).ToNot(BeEmpty()) + Expect(meta.Connectors).ToNot(BeEmpty()) + }) + It("creates an agent that can be read back", func() { + callToolOK(ctx, session, "create_agent", map[string]any{ + "name": "researcher", + "description": "digs things up", + "model": "custom-model", + "system_prompt": "You are a researcher.", + }, nil) + + cfg := state.AgentConfig{} + callToolOK(ctx, session, "get_agent_config", agentNameArgs{Name: "researcher"}, &cfg) + + Expect(cfg.Name).To(Equal("researcher")) + Expect(cfg.Description).To(Equal("digs things up")) + Expect(cfg.Model).To(Equal("custom-model")) + Expect(cfg.SystemPrompt).To(Equal("You are a researcher.")) + }) + It("lists the agents in the pool", func() { + callToolOK(ctx, session, "create_agent", map[string]any{ + "name": "alpha", "description": "the first one", "model": "model-a", + }, nil) + callToolOK(ctx, session, "create_agent", map[string]any{"name": "beta"}, nil) + + listed := listAgentsResult{} + callToolOK(ctx, session, "list_agents", struct{}{}, &listed) + + names := []string{} + for _, a := range listed.Agents { + names = append(names, a.Name) + } + Expect(names).To(ConsistOf("alpha", "beta")) + + for _, a := range listed.Agents { + if a.Name == "alpha" { + Expect(a.Description).To(Equal("the first one")) + Expect(a.Model).To(Equal("model-a")) + Expect(a.Paused).To(BeFalse()) + } + } + }) + + It("replaces the configuration of an existing agent", func() { + callToolOK(ctx, session, "create_agent", map[string]any{ + "name": "editme", "system_prompt": "before", + }, nil) + + callToolOK(ctx, session, "update_agent_config", map[string]any{ + "name": "editme", + "config": map[string]any{ + "name": "editme", + "system_prompt": "after", + "enable_kb": true, + }, + }, nil) + + cfg := state.AgentConfig{} + callToolOK(ctx, session, "get_agent_config", agentNameArgs{Name: "editme"}, &cfg) + Expect(cfg.SystemPrompt).To(Equal("after")) + Expect(cfg.EnableKnowledgeBase).To(BeTrue()) + }) + + It("deletes an agent", func() { + callToolOK(ctx, session, "create_agent", map[string]any{"name": "temporary"}, nil) + callToolOK(ctx, session, "delete_agent", agentNameArgs{Name: "temporary"}, nil) + + res := callTool(ctx, session, "get_agent_config", agentNameArgs{Name: "temporary"}) + Expect(res.IsError).To(BeTrue()) + }) + + It("pauses and resumes an agent", func() { + callToolOK(ctx, session, "create_agent", map[string]any{"name": "sleepy"}, nil) + + callToolOK(ctx, session, "pause_agent", agentNameArgs{Name: "sleepy"}, nil) + listed := listAgentsResult{} + callToolOK(ctx, session, "list_agents", struct{}{}, &listed) + Expect(listed.Agents[0].Paused).To(BeTrue()) + + callToolOK(ctx, session, "start_agent", agentNameArgs{Name: "sleepy"}, nil) + callToolOK(ctx, session, "list_agents", struct{}{}, &listed) + Expect(listed.Agents[0].Paused).To(BeFalse()) + }) + + It("reports a tool error for an unknown agent", func() { + for _, tool := range []string{"get_agent_config", "delete_agent", "pause_agent", "start_agent"} { + res := callTool(ctx, session, tool, agentNameArgs{Name: "ghost"}) + Expect(res.IsError).To(BeTrue(), "%s should have failed", tool) + Expect(textOf(res)).To(ContainSubstring("ghost")) + } + }) + + It("rejects a create call that omits the name", func() { + _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "create_agent", + Arguments: map[string]any{"description": "nameless"}, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("name")) + }) + + It("refuses to create an agent with an empty name", func() { + res := callTool(ctx, session, "create_agent", map[string]any{"name": ""}) + Expect(res.IsError).To(BeTrue()) + Expect(textOf(res)).To(ContainSubstring("name is required")) + }) + + It("refuses to create an agent that already exists", func() { + callToolOK(ctx, session, "create_agent", map[string]any{"name": "twice"}, nil) + + res := callTool(ctx, session, "create_agent", map[string]any{"name": "twice"}) + Expect(res.IsError).To(BeTrue()) + Expect(textOf(res)).To(ContainSubstring("already exists")) + }) + + It("refuses to update an agent that does not exist", func() { + res := callTool(ctx, session, "update_agent_config", map[string]any{ + "name": "ghost", + "config": map[string]any{"name": "ghost"}, + }) + Expect(res.IsError).To(BeTrue()) + Expect(textOf(res)).To(ContainSubstring("ghost")) + }) +}) + +var _ = Describe("MCP HTTP endpoint", func() { + It("serves the MCP protocol at /mcp", func() { + pool := newTestPool(GinkgoT().TempDir()) + app := &App{config: NewConfig(WithPool(pool))} + + webapp := fiber.New() + app.registerMCPRoutes(pool, webapp) + + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + + `"protocolVersion":"2025-06-18","capabilities":{},` + + `"clientInfo":{"name":"test","version":"v1"}}}` + + req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := webapp.Test(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + + payload, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(payload)).To(ContainSubstring("LocalAGI")) + }) +}) diff --git a/webui/routes.go b/webui/routes.go index c000eb2..0b594cc 100644 --- a/webui/routes.go +++ b/webui/routes.go @@ -214,6 +214,9 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { webapp.Post("/api/git-repos/:id/sync", app.SyncGitRepo) webapp.Post("/api/git-repos/:id/toggle", app.ToggleGitRepo) + // Model Context Protocol endpoint, exposing agent management to MCP clients. + app.registerMCPRoutes(pool, webapp) + // Collections / knowledge base API (LocalRecall-compatible). Same interface for in-process or remote. var collectionsBackend CollectionsBackend if app.config.LocalRAGURL != "" { diff --git a/webui/webui_suite_test.go b/webui/webui_suite_test.go new file mode 100644 index 0000000..3995c3c --- /dev/null +++ b/webui/webui_suite_test.go @@ -0,0 +1,13 @@ +package webui + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWebUI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "WebUI Suite") +}