Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ettore Di Giacinto
2026-08-25 17:42:16 +00:00
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")
}