mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-26 18:47:04 -04:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user