Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions pkg/config/agents/nanobot.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ If you have access to Obot MCP Server discovery tools, use them to find MCP serv

**Only recommend MCP servers available through Obot.** Do not attempt to discover, install, or suggest MCP servers from external sources.

# MCP CLI

- `mcp-cli` is installed and its config is managed automatically. Do not create or edit the config yourself.
- Use these subcommands: `mcp-cli`, `mcp-cli info <server>`, `mcp-cli info <server> <tool>`, `mcp-cli grep "<pattern>"`, `mcp-cli call <server> <tool> '<json>'`.
- Before calling an MCP tool with `mcp-cli`, first run `mcp-cli info <server>` and then `mcp-cli info <server> <tool>`.
- Use `call`, not `run`.
- `mcp-cli server tool` is ambiguous. Use `mcp-cli call server tool ...` or `mcp-cli info server tool`.
- Both `<server> <tool>` and `<server>/<tool>` formats work.
- After connecting or configuring an MCP server in Obot, call `refreshMCPServerConfig` so it appears in `mcp-cli` immediately.
- If the `mcp-cli` config appears stale later, call `refreshMCPServerConfig` again.

# Environment

Each user has a **dedicated cloud-based virtual computer** (a Linux sandbox) provisioned on their behalf. All file operations — reading, writing, editing, running scripts — happen inside this virtual machine, **not on the user's local desktop or personal device**. Think of it as a private workspace in the cloud where you can create projects, store files, and run commands. When referring to files or the working directory, always frame it in terms of this virtual environment.
5 changes: 5 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/nanobot-ai/nanobot/pkg/servers/agent"
"github.com/nanobot-ai/nanobot/pkg/servers/artifacts"
"github.com/nanobot-ai/nanobot/pkg/servers/meta"
"github.com/nanobot-ai/nanobot/pkg/servers/obotmcp"
"github.com/nanobot-ai/nanobot/pkg/servers/skills"
"github.com/nanobot-ai/nanobot/pkg/servers/system"
"github.com/nanobot-ai/nanobot/pkg/servers/workflows"
Expand Down Expand Up @@ -109,6 +110,10 @@ func NewRuntime(cfg llm.Config, opts ...Options) (*Runtime, error) {
return system.NewServer(opt.ConfigDir)
})

registry.AddServer("nanobot.obot-mcp-cli", func(string) mcp.MessageHandler {
return obotmcp.NewServer(opt.ConfigDir)
})

registry.AddServer("nanobot.workflows", func(string) mcp.MessageHandler {
return workflows.NewServer()
})
Expand Down
127 changes: 127 additions & 0 deletions pkg/servers/obotmcp/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package obotmcp

import (
"context"
"errors"
"log/slog"
"strings"

"github.com/nanobot-ai/nanobot/pkg/mcp"
"github.com/nanobot-ai/nanobot/pkg/types"
)

const configuredMCPServersPromptSessionKey = "configuredMCPServersPrompt"

// ConfigureIntegration injects the Obot discovery MCP server and appends a snapshot of the user's configured
// Obot MCP servers to the system prompt.
func ConfigureIntegration(ctx context.Context, configDir string, agent *types.HookAgent, params *types.AgentConfigHook) {
configureIntegration(ctx, configDir, agent, params, obotConnectedServerLister{})
}

func configureIntegration(ctx context.Context, configDir string, agent *types.HookAgent, params *types.AgentConfigHook, lister connectedServerLister) {
session := mcp.SessionFromContext(ctx)
if session == nil {
return
}

envMap := session.GetEnvMap()
searchURL := envMap["MCP_SERVER_SEARCH_URL"]
if searchURL == "" {
return
}

params.MCPServers["nanobot.obot-mcp-cli"] = types.AgentConfigHookMCPServer{}
agent.Tools = append(agent.Tools, "nanobot.obot-mcp-cli/refreshMCPServerConfig")

Comment on lines +33 to +35
mcpServer := types.AgentConfigHookMCPServer{URL: searchURL}
if apiKey := envMap["MCP_SERVER_SEARCH_API_KEY"]; apiKey != "" {
mcpServer.Headers = map[string]string{
"Authorization": "Bearer " + apiKey,
}
}

params.MCPServers["mcp-server-search"] = mcpServer
agent.Tools = append(agent.Tools, "mcp-server-search")

configPath := mcpCLIConfigPath(configDir)
needsConfigRefresh := configNeedsRefresh(configPath)

root := session.Root()
var configuredServersPrompt string
needsPromptGenerated := !root.Get(configuredMCPServersPromptSessionKey, &configuredServersPrompt)

if !needsConfigRefresh && !needsPromptGenerated {
agent.Instructions.Instructions += configuredServersPrompt
return
}

var (
servers []ConnectedServer
err error
)
if needsConfigRefresh || needsPromptGenerated {
servers, err = lister.ConnectedMCPServers(ctx)
if errors.Is(err, ErrSearchNotConfigured) {
if needsPromptGenerated {
// Obot mcp search server not configured, so we don't need to generate a prompt
root.Set(configuredMCPServersPromptSessionKey, mcp.SavedString(""))
} else {
agent.Instructions.Instructions += configuredServersPrompt
}
return
}
}

if needsConfigRefresh {
if err != nil {
slog.Warn("skipping mcp-cli config refresh during Obot integration setup because connected server fetch failed",
"path", configPath,
"error", err)
} else {
if _, writeErr := prepareMCPCLIConfigWithServers(configPath, servers); writeErr != nil {
slog.Warn("failed to prepare mcp-cli config during Obot integration setup", "error", writeErr)
}
}
}

if needsPromptGenerated {
if err != nil {
slog.Warn("failed to build configured MCP servers prompt snapshot", "error", err)
} else {
configuredServersPrompt = buildConfiguredMCPServersPrompt(servers)
root.Set(configuredMCPServersPromptSessionKey, mcp.SavedString(configuredServersPrompt))
}
}
agent.Instructions.Instructions += configuredServersPrompt
}

func buildConfiguredMCPServersPrompt(servers []ConnectedServer) string {
var prompt strings.Builder
prompt.WriteString("\n\n## Configured MCP Servers\n\n")
prompt.WriteString("This is a snapshot of the user's configured MCP servers from when this session first built its system prompt. ")
prompt.WriteString("It can change later if the user connects or configures new MCP servers. If that happens, call `refreshMCPServerConfig`.\n\n")

entries := buildInventoryEntries(servers)
if len(entries) == 0 {
prompt.WriteString("- No MCP servers were configured at snapshot time.\n")
return prompt.String()
}

for _, entry := range entries {
prompt.WriteString("- `")
prompt.WriteString(entry.Name)
prompt.WriteString("`: ")
if entry.Server.Name != "" {
prompt.WriteString(entry.Server.Name)
} else {
prompt.WriteString(entry.Server.ID)
}
if entry.Server.Description != "" {
prompt.WriteString(" - ")
prompt.WriteString(entry.Server.Description)
}
prompt.WriteString("\n")
}

return prompt.String()
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package system
package obotmcp

import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"time"

"github.com/nanobot-ai/nanobot/pkg/mcp"
"github.com/nanobot-ai/nanobot/pkg/types"
"log/slog"
)

var (
Expand Down Expand Up @@ -54,6 +54,7 @@ type RemoveMCPServerParams struct {
}

func (s *Server) addMCPServer(ctx context.Context, params AddMCPServerParams) (map[string]any, error) {
// TODO Once we feel fully committed to the mcp-cli approach, remove all the logic related to dynamic servers.
if params.URL == "" {
return nil, mcp.ErrRPCInvalidParams.WithMessage("url is required")
}
Expand Down
101 changes: 101 additions & 0 deletions pkg/servers/obotmcp/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package obotmcp

import (
"context"
"errors"
"fmt"
"strings"
"time"

"github.com/nanobot-ai/nanobot/pkg/mcp"
)

var ErrSearchNotConfigured = errors.New("MCP_SERVER_SEARCH_URL is not configured")

type connectedServerLister interface {
ConnectedMCPServers(context.Context) ([]ConnectedServer, error)
}

type obotConnectedServerLister struct{}

func (obotConnectedServerLister) ConnectedMCPServers(ctx context.Context) ([]ConnectedServer, error) {
return fetchConnectedMCPServers(ctx)
}

type connectedServersResult struct {
ConnectedServers []ConnectedServer `json:"connected_servers"`
}

func fetchConnectedMCPServers(ctx context.Context) ([]ConnectedServer, error) {
session := mcp.SessionFromContext(ctx)
if session == nil {
return nil, nil
}

envMap := session.GetEnvMap()
searchURL := strings.TrimSpace(envMap["MCP_SERVER_SEARCH_URL"])
if searchURL == "" {
return nil, ErrSearchNotConfigured
}

headers := map[string]string{}
if apiKey := strings.TrimSpace(envMap["MCP_API_KEY"]); apiKey != "" {
headers["Authorization"] = "Bearer " + apiKey
} else if apiKey := strings.TrimSpace(envMap["MCP_SERVER_SEARCH_API_KEY"]); apiKey != "" {
headers["Authorization"] = "Bearer " + apiKey
}

clientCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

client, err := mcp.NewClient(clientCtx, "obot-connected-servers", mcp.Server{
BaseURL: searchURL,
Headers: headers,
})
if err != nil {
return nil, fmt.Errorf("connect to obot-connected-servers: %w", err)
}
defer client.Close(true)

result, err := client.Call(clientCtx, "obot_list_connected_mcp_servers", map[string]any{})
if err != nil {
return nil, err
}

return extractConnectedMCPServers(result)
}

func extractConnectedMCPServers(result *mcp.CallToolResult) ([]ConnectedServer, error) {
if result == nil {
return nil, nil
}

var payload connectedServersResult
if result.StructuredContent != nil {
if err := mcp.JSONCoerce(result.StructuredContent, &payload); err == nil {
return payload.ConnectedServers, nil
} else {
return nil, fmt.Errorf("decode connected MCP servers from structured content: %w", err)
}
}

var sawTextContent bool
for _, content := range result.Content {
if content.Type != "text" || strings.TrimSpace(content.Text) == "" {
continue
}
sawTextContent = true

if err := mcp.JSONCoerce(content.Text, &payload); err == nil {
return payload.ConnectedServers, nil
} else {
return nil, fmt.Errorf("decode connected MCP servers from text content: %w", err)
}
}

if sawTextContent {
return nil, fmt.Errorf("decode connected MCP servers: no parseable text payload")
}

return nil, nil
}
Loading
Loading