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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wnxzYVrUZ7xtpqAGHM474
This commit is contained in:
Ettore Di Giacinto
2026-08-24 18:54:08 +00:00
parent 6a0e35d28e
commit 391e007bc2
6 changed files with 641 additions and 1 deletions
+41
View File
@@ -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 <your-api-key>` 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
+1 -1
View File
@@ -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
+296
View File
@@ -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)
}
+287
View File
@@ -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"))
})
})
+3
View File
@@ -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 != "" {
+13
View File
@@ -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")
}