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") +}