From 6a0e35d28edad92e863f801ce6d29cc89319e0a0 Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:33:54 +0200 Subject: [PATCH 1/2] fix(telegram): accumulate streamed reasoning (#490) Cogito emits reasoning as incremental fragments. The Telegram session replaced its status for every fragment, so users saw one word at a time. Accumulate consecutive reasoning fragments while preserving replacement semantics for tool and status transitions. Answer content still takes priority when it begins. Co-authored-by: Ettore Di Giacinto --- .../2026-08-21-telegram-rich-streaming-design.md | 15 ++++++++------- services/connectors/telegram_stream.go | 9 ++++++++- services/connectors/telegram_stream_test.go | 5 +++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-08-21-telegram-rich-streaming-design.md b/docs/superpowers/specs/2026-08-21-telegram-rich-streaming-design.md index 6242255..5778015 100644 --- a/docs/superpowers/specs/2026-08-21-telegram-rich-streaming-design.md +++ b/docs/superpowers/specs/2026-08-21-telegram-rich-streaming-design.md @@ -2,7 +2,7 @@ ## Summary -The Telegram connector streams agent output as Telegram renders it. Private chats use native rich-message drafts. Other chats use progressive message edits. The connector sends the final answer as a persistent rich message. +The Telegram connector streams agent output as Telegram renders it. Private chats use native message drafts. Other chats use progressive message edits. The connector sends the final answer as a persistent rich message. Streaming is enabled by default. Operators can disable it with the connector setting `streaming=false`. @@ -27,7 +27,7 @@ Streaming is enabled by default. Operators can disable it with the connector set ### Private chats -The connector starts a native rich draft with a Telegram thinking block. It appends answer deltas to the draft as the model produces them. Telegram animates updates that use the same nonzero draft ID. +The connector starts a native draft with a Telegram thinking block. It appends answer deltas to the draft as the model produces them. Telegram animates updates that use the same nonzero draft ID. The connector sends the completed answer with `sendRichMessage`. The persistent message uses the model's raw Markdown. The ephemeral draft then expires according to Telegram behavior. @@ -37,7 +37,7 @@ The connector sends the existing thinking placeholder as a reply. It edits that ### Reasoning and tool activity -Before answer content starts, the preview can show reasoning and tool activity through the existing status accumulator. Answer content takes priority after the first content delta. The connector does not expose hidden reasoning that the agent does not already publish. +Before answer content starts, the preview can show reasoning and tool activity. Consecutive reasoning deltas append to the visible reasoning buffer; they never replace the preceding delta. Tool status events replace the status text because they describe state transitions rather than token fragments. Answer content takes priority after the first content delta. The connector does not expose hidden reasoning that the agent does not already publish. ### Disabled streaming @@ -64,7 +64,7 @@ Each incoming Telegram request creates one stream session. The session owns thes - Draft ID or placeholder message ID. - Accumulated answer content. - The most recent delivered content. -- Delivery mode: rich draft, edited message, or final-only. +- Delivery mode: native draft, edited message, or final-only. - Throttle timer and cancellation context. The request callback only appends event data and signals the session worker. One worker performs Telegram API calls in order. This structure avoids concurrent edits and protects the model callback from network latency. @@ -75,7 +75,7 @@ The handler closes and flushes the session before final delivery. Cleanup stops The pinned `go-telegram/bot` version does not expose the Bot API 10.2 rich-message methods. A small internal client calls these methods through the Bot API HTTP endpoint: -- `sendRichMessageDraft` +- `sendMessageDraft` - `sendRichMessage` The client uses typed request and response structures. It uses the request context and returns Telegram's error description. Existing operations continue to use `go-telegram/bot`. @@ -100,7 +100,7 @@ The connector does not call `bot.EscapeMarkdown` on rich Markdown. The current e Delivery uses this fallback order: -1. Send a rich draft in a private chat. +1. Send a native text draft in a private chat. 2. If the draft fails, switch that response to progressive edits. 3. Finalize with a rich persistent message or rich edit. 4. If rich formatting fails, convert the content to Telegram MarkdownV2. @@ -144,7 +144,8 @@ No token or BotFather setting is required. Unsupported Bot API servers use the f Unit tests use named, table-driven subtests where cases share a contract. Tests cover these behaviors: -- Private chats start and update one rich draft with a stable draft ID. +- Private chats start and update one native draft with a stable draft ID. +- Consecutive reasoning deltas accumulate instead of showing one token at a time. - Private chats send one persistent rich final response. - Groups edit one placeholder instead of creating a draft. - Character deltas coalesce into throttled updates. diff --git a/services/connectors/telegram_stream.go b/services/connectors/telegram_stream.go index a0d2aab..4ea5fcb 100644 --- a/services/connectors/telegram_stream.go +++ b/services/connectors/telegram_stream.go @@ -44,6 +44,7 @@ type telegramStreamSession struct { mu sync.Mutex content string status string + reasoningActive bool version uint64 dirty bool thinkingPending bool @@ -102,7 +103,13 @@ func (s *telegramStreamSession) Accept(event cogito.StreamEvent) { if event.Type == cogito.StreamEventContent && event.Content != "" { s.content += event.Content } else if s.content == "" { - s.status = telegramStreamStatus(event) + status := telegramStreamStatus(event) + if event.Type == cogito.StreamEventReasoning && s.reasoningActive { + s.status += status + } else { + s.status = status + } + s.reasoningActive = event.Type == cogito.StreamEventReasoning } s.version++ s.dirty = true diff --git a/services/connectors/telegram_stream_test.go b/services/connectors/telegram_stream_test.go index 1b643c6..5382401 100644 --- a/services/connectors/telegram_stream_test.go +++ b/services/connectors/telegram_stream_test.go @@ -328,12 +328,13 @@ func TestTelegramStreamPreviewUsesUTF8SafeTail(t *testing.T) { } } -func TestTelegramStreamPrivateShowsPublishedStatusBeforeContent(t *testing.T) { +func TestTelegramStreamPrivateAccumulatesReasoningBeforeContent(t *testing.T) { api := &telegramStreamAPI{} s := newTelegramStreamSession(context.Background(), api, 5, true, telegramStreamDelivery{}) defer s.Close() waitTelegramStream(t, func() bool { d, _ := api.snapshot(); return len(d) == 1 }) - s.Accept(cogito.StreamEvent{Type: cogito.StreamEventReasoning, Content: "checking sources"}) + s.Accept(cogito.StreamEvent{Type: cogito.StreamEventReasoning, Content: "checking "}) + s.Accept(cogito.StreamEvent{Type: cogito.StreamEventReasoning, Content: "sources"}) if err := s.Flush(); err != nil { t.Fatal(err) } From 391e007bc2fa2c60987bd4275109c7339beb47f7 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 24 Aug 2026 18:54:08 +0000 Subject: [PATCH 2/2] 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") +}