mcp

package
v0.7.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 27 Imported by: 0

README

MCP integration

internal/pkg/mcp owns admin-managed Model Context Protocol connections: stdio and streamable HTTP transports, live status, tool routing, credentials, imports, and automatic recovery. Per-chat selection lives in internal/pkg/core/chats.

Contents

Lifecycle and recovery

Creating/importing a server persists its configuration then calls Manager.ConnectAsync, so an unavailable endpoint cannot block the admin request. Status is connecting, connected, failed, or disabled; a connected server includes its discovered tool count. The admin response also retains the latest attempt, success, failure, connection latency, and last connection error, so an operator can distinguish a current failure from an in-progress reconnect. This diagnostic history excludes credentials and tool payloads.

Each attempt has a 10-second deadline plus a five-second outer grace that force-settles a hung attempt as failed/not_responding. Initial failures and unexpected session deaths retry after 15 seconds. Generation guards prevent a late old attempt from overwriting an edit or reconnect. Disabling, removing, or replacing a server cancels its retry.

Transports and tools

Stdio stores command, args, and an optional environment map. The MCP session starts/owns that subprocess. HTTP stores a streamable-MCP URL and optional static headers; chatz decrypts them at connect time and applies them to every MCP request, including Authorization.

Tools are exposed as <server>__<tool> and routed back to the owning client. A tool-level failure becomes an IsError=true tool result and remains visible; transport/RPC failure remains an error.

Credentials and selection

CHATZ_SECRETS_KEY is a base64-encoded 32-byte AES-256-GCM key. It seals every stored HTTP header and stdio environment map. If unset, storage fails instead of falling back to plaintext.

All headers are sealed. Authorization is additionally masked in admin responses: it returns as <scheme> [SECRET]; submitting that unchanged mask retains the stored value, omission removes it, and any other value replaces it. Other headers are shown as configured. Stdio environment values are decrypted for the trusted admin edit form. Keep keys and bearer values only in gitignored .env. The mask/restore logic is in internal/pkg/http/server/handler_mcp.go.

Globally enabled connected servers are offered to all chats by default. A composer can disable one server for one chat, hiding its tools from that model. That toggle is a preference, not a security boundary.

Claude-style .mcp.json import accepts mcpServers entries with either command/args/env (stdio) or url/headers (HTTP). If both command and URL exist, stdio wins. Names are sorted for deterministic output.

Files and verification

Path Responsibility
manager.go Connections, statuses, retries, aggregated tools.
client.go SDK session, transports, and tool calls.
import.go .mcp.json parsing and sealed models.
to_api.go Admin API projection.

Run make test for transport/import/retry coverage. The real-browser MCP flows (per-chat picker + admin connect/tools/edit/reconnect/fail) live in the Go e2e suite: tests/api/chat_mcp_test.go and tests/api/mcp_admin_test.go, run via make test-api.

Documentation

Overview

Package mcp connects chatz to MCP servers (stdio + http) and imports their configuration. Servers live in the DB (admin-managed via the UI) or are imported from a Claude-style .mcp.json here. Header + env secrets are sealed at rest via internal/pkg/secrets — plaintext never touches the DB.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoServers is returned when a .mcp.json has no mcpServers entries.
	ErrNoServers = errors.New("mcp: no servers in file")

	// ErrInvalidServer is returned for an entry that is neither a stdio server
	// (has command) nor an http server (has url).
	ErrInvalidServer = errors.New("mcp: server has neither command nor url")

	// ErrUnsupportedTransport is returned when a server's transport is neither
	// stdio nor http.
	ErrUnsupportedTransport = errors.New("mcp: unsupported transport")

	// ErrInvalidToolName is returned when a qualified tool name is not of the
	// form <server>__<tool>.
	ErrInvalidToolName = errors.New("mcp: invalid qualified tool name")

	// ErrServerNotFound is returned when a call targets a server not registered
	// with the manager.
	ErrServerNotFound = errors.New("mcp: server not found")
)

MCP errors. Declared with errors.New so they stay comparable across ctxerrors.Wrap layers via errors.Is.

Functions

func ParseMCPJSON

func ParseMCPJSON(
	raw []byte,
	box *secrets.Box,
	createdBy *uuid.UUID,
) ([]*models.MCPServer, error)

ParseMCPJSON parses raw .mcp.json bytes into MCPServer rows ready to persist, sealing env + header secrets with box. createdBy stamps the importing admin (nil is allowed). Servers come back sorted by name for deterministic output.

func ServerToAPI

func ServerToAPI(m *models.MCPServer, st Status) api.MCPServer

ServerToAPI projects a stored MCP server row plus its live status to the wire shape. Optional command/url/error/reason/toolCount fields stay nil unless the row or status actually carries them.

func ToolToAPI

func ToolToAPI(t Tool) api.MCPTool

ToolToAPI projects a discovered tool to the wire shape.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a live connection to one MCP server.

func Connect

func Connect(
	ctx context.Context,
	srv *models.MCPServer,
	box *secrets.Box,
) (*Client, error)

Connect dials the server described by srv, decrypting its env/header secrets with box, and completes the MCP initialize handshake. ctx bounds the connect; the session outlives it (a stdio subprocess is torn down by Close, not ctx).

func (*Client) CallTool

func (c *Client) CallTool(
	ctx context.Context,
	tool string,
	args map[string]any,
) (*ToolResult, error)

CallTool invokes tool (the raw server-side name) with args. A protocol failure returns an error; a tool-level failure returns a result with IsError set.

func (*Client) Close

func (c *Client) Close() error

Close disconnects the session (and tears down a stdio subprocess).

func (*Client) ListTools

func (c *Client) ListTools(ctx context.Context) ([]Tool, error)

ListTools enumerates the server's tools (auto-paginated), namespaced to this server.

func (*Client) Wait

func (c *Client) Wait() error

Wait blocks until the underlying session closes, deliberately or not, and returns the terminal error (nil on a clean close). A caller uses this to detect the connection dying in the background outside any specific RPC — see WasIntentionalClose to tell that apart from an expected shutdown.

func (*Client) WasIntentionalClose

func (c *Client) WasIntentionalClose() bool

WasIntentionalClose reports whether Close has been called on this client — distinguishes a deliberate shutdown from an unexpected async death for a Wait caller.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager holds live connections to multiple MCP servers, aggregates their tools (namespaced <server>__<tool>), and routes a qualified tool call back to the owning server. Safe for concurrent use.

func NewManager

func NewManager(box *secrets.Box) *Manager

NewManager builds an empty manager. box decrypts each server's env/header secrets at connect time.

func (*Manager) Add

func (m *Manager) Add(ctx context.Context, srv *models.MCPServer) error

Add connects to srv synchronously, registering it under its name (replacing and closing any existing client) and recording its status. A failure is recorded as a failed status AND returned so callers that want the error get it. Prefer ConnectAsync from request handlers so a slow server can't block.

Bumps the server's generation first so this attempt always wins the status write over any in-flight ConnectAsync goroutine for the same name — Add is synchronous and its result is authoritative for its caller.

func (*Manager) Call

func (m *Manager) Call(
	ctx context.Context,
	qualifiedName string,
	args map[string]any,
) (*ToolResult, error)

Call routes a qualified tool name (<server>__<tool>) to its server and invokes it with args. Logs start/end with timing, and heartbeats every heartbeat.Interval while the call is still in flight — CallTool is a single blocking round trip to (potentially) a slow external server with no intermediate progress signal, so without a heartbeat a slow tool looks identical to a hang in the logs.

Only argument KEY NAMES are logged, never values — tool-call arguments can carry arbitrary (and potentially sensitive) caller-supplied data.

func (*Manager) Close

func (m *Manager) Close() error

Close disconnects every registered server, joining any close errors, and stops every pending auto-retry timer so nothing fires after the manager (and the DB/process it's part of) is torn down.

func (*Manager) ConnectAsync

func (m *Manager) ConnectAsync(ctx context.Context, srv *models.MCPServer)

ConnectAsync marks the server connecting and connects in the background, so the caller's request returns immediately and the recorded status settles to connected/failed on its own.

The status is GUARANTEED to leave StateConnecting within connectTimeout + connectOuterGrace, regardless of how the inner connect attempt behaves. connectAndStore's own ctx (bounded by connectTimeout) relies on the MCP SDK selecting on ctx.Done() for its I/O, which it does for the read/response path — but ctx-UNBOUND work can still follow (e.g. Close() waiting out a stuck subprocess's SIGTERM grace period). Rather than trust every layer to honor ctx, this races connectAndStore against cctx's own deadline with an explicit select: if the deadline wins, the status is force-settled to failed/not_responding immediately and the inner attempt is left to finish (or leak) in the background, logged as a WARN.

A monotonic per-server generation number, bumped here before the goroutine starts, guards against a late result from an abandoned attempt clobbering a newer one: if a Reconnect (or another ConnectAsync/Add) supersedes this attempt before it finishes, its result — early or late — is discarded.

func (*Manager) Disable

func (m *Manager) Disable(ctx context.Context, name string)

Disable disconnects a server and records it as disabled — used when a server is edited to enabled=false (the row stays, its tools go away).

Bumps the generation first for the same reason as Remove: a superseded in-flight connect attempt must not overwrite the disabled status. Also cancels any pending auto-retry — a disabled server must never reconnect on its own.

func (*Manager) Remove

func (m *Manager) Remove(ctx context.Context, name string)

Remove disconnects a server (if connected) and forgets its status entirely — used when a server is deleted.

Bumps the generation first so a ConnectAsync attempt already in flight for this name (started before the delete) can never resurrect a status after removal, early or late. Also cancels any pending auto-retry — a deleted server must never reconnect on its own.

func (*Manager) ServerTools

func (m *Manager) ServerTools(
	ctx context.Context,
	name string,
) ([]Tool, error)

ServerTools lists a connected server's tools (unqualified names + description + input schema). A server that is not connected yields nil, nil — the admin UI shows tools for a live server only.

func (*Manager) Status

func (m *Manager) Status(name string) Status

Status returns the last-known status for a server name. An unknown server yields the zero Status (State ""), which the API layer maps by enabled-ness.

func (*Manager) Tools

func (m *Manager) Tools(ctx context.Context) []Tool

Tools aggregates the tools across all registered servers, sorted by qualified name. Best-effort: a server that fails to list is warned + skipped so one bad server doesn't hide the rest.

type Reason

type Reason string

Reason classifies WHY a connect failed so the UI can say "unreachable" / "access denied" / "not responding" instead of a raw error blob.

const (
	ReasonUnreachable   Reason = "unreachable"
	ReasonAccessDenied  Reason = "access_denied"
	ReasonNotResponding Reason = "not_responding"
	ReasonFailed        Reason = "failed"
)

type ServerStore

type ServerStore struct {
	// contains filtered or unexported fields
}

ServerStore is the persistence core for MCP server rows. The HTTP handlers drive it instead of reaching into the repositories directly, so the wire layer never knows about gorm — a missing row surfaces as commerr.ErrNotFound, which the handlers map to a 404. The live connection lifecycle stays in Manager; this type owns only the DB side.

func NewServerStore

func NewServerStore(q *repositories.Query) *ServerStore

NewServerStore builds a ServerStore over the given query handle.

func (*ServerStore) Create

func (s *ServerStore) Create(
	ctx context.Context,
	srv *models.MCPServer,
) error

Create persists a new server row.

func (*ServerStore) Delete

func (s *ServerStore) Delete(ctx context.Context, id uuid.UUID) error

Delete removes the server row with id. Deleting a row that isn't there is a no-op (callers Get first to produce a 404), so this never reports not-found.

func (*ServerStore) Get

func (s *ServerStore) Get(
	ctx context.Context,
	id uuid.UUID,
) (*models.MCPServer, error)

Get returns the server with id, or commerr.ErrNotFound if none exists.

func (*ServerStore) List

func (s *ServerStore) List(ctx context.Context) ([]*models.MCPServer, error)

List returns all configured servers ordered by name.

func (*ServerStore) Save

func (s *ServerStore) Save(
	ctx context.Context,
	srv *models.MCPServer,
) error

Save persists changes to an existing server row.

type State

type State string

State is a server's live connection state, surfaced to the admin UI.

const (
	StateConnecting State = "connecting"
	StateConnected  State = "connected"
	StateFailed     State = "failed"
	StateDisabled   State = "disabled"
)

type Status

type Status struct {
	State                      State
	Reason                     Reason
	Error                      string
	ToolCount                  int
	LastConnectionAttemptAt    time.Time
	LastSuccessfulConnectionAt time.Time
	LastConnectionFailureAt    time.Time
	LastConnectionLatency      time.Duration
	LastError                  string
}

Status is a server's last-known connection status (keyed by server name).

type Tool

type Tool struct {
	Server      string         `json:"server"`
	Name        string         `json:"name"`
	Description string         `json:"description"`
	InputSchema map[string]any `json:"inputSchema,omitempty"`
}

Tool is a tool discovered on an MCP server.

func (Tool) QualifiedName

func (t Tool) QualifiedName() string

QualifiedName is the server-namespaced name exposed to the LLM.

type ToolResult

type ToolResult struct {
	Text    string
	IsError bool
}

ToolResult is the outcome of a tool call. IsError is the tool-level error flag (the call itself succeeded at the protocol level); Text is the joined text content.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL