Skip to content

feat: add native MCP integration with context7 compatibility#376

Open
Danieldd28 wants to merge 2 commits intomainfrom
feat/mcp-context7-integration-cleanup
Open

feat: add native MCP integration with context7 compatibility#376
Danieldd28 wants to merge 2 commits intomainfrom
feat/mcp-context7-integration-cleanup

Conversation

@Danieldd28
Copy link
Collaborator

@Danieldd28 Danieldd28 commented Feb 17, 2026

Description

This PR adds native MCP integration with a lean, protocol-aware implementation and Context7 compatibility, while keeping startup and runtime overhead low.

Key outcomes:

  • Adds a dedicated MCP module (pkg/mcp) for client transport, manager lifecycle, naming, and output formatting.
  • Integrates MCP tools into agent/subagent tool registries through a shared manager to avoid duplicate server startup and redundant memory usage.
  • Adds support for both stdio transport modes used by MCP servers:
    • framed JSON-RPC (Content-Length)
    • JSONL JSON-RPC (context7-mcp behavior)
  • Adds compatibility mapping for top-level mcpServers config into canonical tools.mcp.servers.
  • Improves MCP bootstrap timeout behavior for slower server startup paths.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Documentation update
  • Code refactoring (no functional changes, no API changes)

AI Code Generation

  • Fully AI-generated (100% AI, 0% Human)
  • Mostly AI-generated (AI draft, Human verified/modified)
  • Mostly Human-written (Human lead, AI assisted or none)

Linked Issue

  • MCP support roadmap item
  • Startup delay and MCP tool registration issues observed during Context7 integration

Technical Context

  • Reference: MCP stdio integration and Context7 MCP server behavior (context7-mcp emits JSON-RPC as JSONL on stdio)
  • Reasoning:
    • Keep integration modular and lightweight.
    • Avoid duplicate MCP discovery/registration for main agent and subagent.
    • Fail fast on startup misconfiguration while still allowing slower server initialization through bounded timeout strategy.

Changes

New module

  • pkg/mcp/types.go
    • MCP constants, protocol constants, and typed configs/results.
  • pkg/mcp/client.go
    • Stdio MCP client with dual protocol support (mcp, jsonl), request/response dispatching, lifecycle handling.
  • pkg/mcp/manager.go
    • Server lifecycle management, tool discovery/filtering, tool call routing.
  • pkg/mcp/naming.go
    • Stable qualified MCP tool naming.
  • pkg/mcp/format.go
    • Normalized tool call result parsing with output truncation controls.

Agent integration

  • pkg/agent/mcp_bootstrap.go
    • MCP bootstrap, server config conversion, bounded timeout calculation, protocol inference for Context7.
  • pkg/agent/loop.go
    • Shared MCP manager ownership in AgentLoop.
    • Single discovery/bootstrap path.
    • MCP manager close lifecycle managed in Run()/Stop().
    • Removed duplicate MCP registration path from tool registry initialization.

Tool integration

  • pkg/tools/mcp.go
    • MCP tool wrapper and registration helper.
    • Added RegisterKnownMCPTools to reuse discovered tools without repeated discovery.

Config changes

  • pkg/config/config.go
    • Added tools.mcp config structs.
    • Added top-level mcpServers compatibility mapping.
    • Added protocol support in both canonical and legacy MCP config paths.
  • config/config.example.json
    • Added MCP example block with protocol and timeout fields.

Tests

  • pkg/mcp/client_test.go
  • pkg/mcp/naming_test.go
  • pkg/mcp/format_test.go
  • pkg/mcp/manager_test.go
  • pkg/agent/mcp_bootstrap_test.go
  • pkg/tools/mcp_test.go
  • pkg/config/config_test.go (MCP defaults + legacy compatibility)

Test Environment and Hardware

  • Hardware: PC (x86_64)
  • OS: Linux
  • Model/Provider: OpenRouter (arcee-ai/trinity-large-preview:free)
  • Channels: CLI

Validation Performed

  • go test ./pkg/mcp ./pkg/config ./pkg/agent
  • go test ./pkg/tools -run 'TestRegisterKnownMCPTools_RegistersAllTools|TestNewMCPTool|TestRegisterMCPTools'
  • make build
  • Runtime validation:
    • context7-mcp starts successfully via stdio
    • PicoClaw startup logs show MCP registration (count=2)

Proof of Work

Click to view logs and screenshot

Startup validation screenshot:

MCP startup screenshot

Sample startup log excerpt:

[DEBUG] mcp: MCP server stderr {line=Context7 Documentation MCP Server v2.1.1 running on stdio, server=context7}
[INFO] agent: MCP tools registered {count=2}

Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

Copilot AI review requested due to automatic review settings February 17, 2026 17:36
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a native MCP (Model Context Protocol) integration to PicoClaw, including Context7 compatibility, with a new pkg/mcp module and agent/tooling/config wiring to share a single MCP manager across agent + subagents.

Changes:

  • Introduces pkg/mcp with stdio client (framed + JSONL), server manager, tool naming, and response formatting.
  • Integrates discovered MCP tools into tool registries via a shared manager (agent + subagents) and adds bootstrap timeout/protocol inference.
  • Extends config to support tools.mcp.* plus legacy top-level mcpServers compatibility mapping; updates example config and adds tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
pkg/tools/mcp.go MCP tool wrapper + helpers to register discovered tools without re-discovery.
pkg/tools/mcp_test.go Test coverage for registering known MCP tools into the registry.
pkg/mcp/types.go MCP config/types + defaults and constants.
pkg/mcp/client.go Stdio MCP client implementation supporting framed JSON-RPC and JSONL modes.
pkg/mcp/client_test.go Tests for protocol normalization.
pkg/mcp/manager.go MCP server lifecycle + tool discovery/filtering + tool call routing.
pkg/mcp/manager_test.go Tests for discovery filtering, call routing, and schema normalization.
pkg/mcp/naming.go Stable MCP tool naming + sanitization and length bounds.
pkg/mcp/naming_test.go Tests for tool naming sanitization and max length behavior.
pkg/mcp/format.go Normalization/truncation of MCP tool call results.
pkg/mcp/format_test.go Tests for formatting, truncation, and isError handling.
pkg/agent/mcp_bootstrap.go MCP bootstrap helpers: config conversion, protocol inference, discovery timeout.
pkg/agent/mcp_bootstrap_test.go Tests for timeout calculation, disabled server filtering, Context7 protocol default.
pkg/agent/loop.go Uses a shared MCP manager across registries; closes MCP lifecycle on Run/Stop.
pkg/config/config.go Adds tools.mcp config, legacy mcpServers mapping, and defaults initialization.
pkg/config/config_test.go Tests MCP defaults + legacy mcpServers compatibility mapping.
config/config.example.json Adds example tools.mcp configuration block.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +122 to +140
func (m *Manager) Close() error {
m.mu.Lock()
servers := make([]*managedServer, 0, len(m.servers))
for _, server := range m.servers {
servers = append(servers, server)
}
m.mu.Unlock()

var firstErr error
for _, server := range servers {
if server.client == nil {
continue
}
if err := server.client.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manager.Close() releases the mutex before closing clients, so CallTool() can concurrently grab a client pointer and invoke client.CallTool() while Close() is shutting it down. That creates a race and can lead to calls on a killed process. Consider, under lock, snapshotting clients and setting server.client=nil (and/or a closed flag) before unlocking; then close the snapshot outside the lock so new calls fail fast and in-flight lookups can’t race shutdown.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +61
func truncateString(value string, maxBytes int) string {
if maxBytes <= 0 || len(value) <= maxBytes {
return value
}
if maxBytes <= 12 {
return value[:maxBytes]
}
return value[:maxBytes-12] + "\n...[truncated]"
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

truncateString() slices by byte count, which can split multi-byte UTF-8 characters and produce invalid UTF-8 in tool output. Since this content is surfaced to the LLM/user, consider truncating to the nearest valid UTF-8 boundary while still honoring the max-bytes limit (or truncate by runes after converting to []rune and then re-check byte length).

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +458
for name, legacy := range c.MCPServers {
if strings.TrimSpace(legacy.Command) == "" {
continue
}

enabled := true
if legacy.Type != "" && legacy.Type != "stdio" {
enabled = false
}

envCopy := make(map[string]string, len(legacy.Env))
for key, value := range legacy.Env {
envCopy[key] = value
}

c.Tools.MCP.Servers[name] = MCPServerConfig{
Enabled: enabled,
Command: legacy.Command,
Args: append([]string{}, legacy.Args...),
Env: envCopy,
Protocol: legacy.Protocol,
}
}

if len(c.Tools.MCP.Servers) > 0 {
c.Tools.MCP.Enabled = true
}
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyLegacyMCPServers() sets Tools.MCP.Enabled=true whenever it maps any legacy entries, even if all mapped servers end up Enabled=false (e.g., legacy.Type != "stdio"). That can leave MCP globally “enabled” while no servers can ever start. Consider only setting Tools.MCP.Enabled if at least one mapped server is enabled (or skip inserting disabled legacy servers entirely).

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +458
func (c *Config) applyLegacyMCPServers() {
// If canonical MCP config already exists, keep it as source of truth.
if len(c.Tools.MCP.Servers) > 0 {
return
}
if len(c.MCPServers) == 0 {
return
}

if c.Tools.MCP.Servers == nil {
c.Tools.MCP.Servers = map[string]MCPServerConfig{}
}

for name, legacy := range c.MCPServers {
if strings.TrimSpace(legacy.Command) == "" {
continue
}

enabled := true
if legacy.Type != "" && legacy.Type != "stdio" {
enabled = false
}

envCopy := make(map[string]string, len(legacy.Env))
for key, value := range legacy.Env {
envCopy[key] = value
}

c.Tools.MCP.Servers[name] = MCPServerConfig{
Enabled: enabled,
Command: legacy.Command,
Args: append([]string{}, legacy.Args...),
Env: envCopy,
Protocol: legacy.Protocol,
}
}

if len(c.Tools.MCP.Servers) > 0 {
c.Tools.MCP.Enabled = true
}
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After applying legacy mcpServers into tools.mcp.servers, the original Config.MCPServers field remains populated. If the config is later saved/migrated, this can emit both the legacy top-level mcpServers and the canonical tools.mcp.servers, creating ambiguity about the source of truth. Consider clearing c.MCPServers (or omitting it during SaveConfig) after a successful mapping to keep the serialized config canonical.

Copilot uses AI. Check for mistakes.
Comment on lines +568 to +569
if _, hasDeadline := parent.Deadline(); hasDeadline {
return context.WithCancel(parent)
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withTimeoutIfMissing() disables the per-server InitTimeout/CallTimeout whenever the parent context already has a deadline (it returns WithCancel instead of applying the configured timeout). In bootstrap and in normal tool execution this means requests can run much longer than configured, defeating the timeout knobs and risking hangs. Consider enforcing the configured timeout as an upper bound (use the earlier of the parent deadline and now+timeout) rather than skipping timeouts when a deadline exists.

Suggested change
if _, hasDeadline := parent.Deadline(); hasDeadline {
return context.WithCancel(parent)
// If the parent already has a deadline, enforce the configured timeout as an upper bound
// by using the earlier of the parent deadline and now+timeout.
if parentDeadline, hasDeadline := parent.Deadline(); hasDeadline {
// If timeout is non-positive, don't attempt to shorten or extend the existing deadline.
if timeout <= 0 {
return context.WithCancel(parent)
}
timeoutDeadline := time.Now().Add(timeout)
if timeoutDeadline.Before(parentDeadline) {
return context.WithDeadline(parent, timeoutDeadline)
}
return context.WithDeadline(parent, parentDeadline)

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +64
func calculateMCPDiscoveryTimeout(serverConfigs map[string]mcp.ServerConfig) time.Duration {
maxInitTimeout := mcpBootstrapMinTimeout

for _, serverConfig := range serverConfigs {
initTimeout := serverConfig.InitTimeout()
if initTimeout > maxInitTimeout {
maxInitTimeout = initTimeout
}
}

timeout := maxInitTimeout + mcpBootstrapGraceTimeout
if timeout < mcpBootstrapMinTimeout {
return mcpBootstrapMinTimeout
}
if timeout > mcpBootstrapMaxTimeout {
return mcpBootstrapMaxTimeout
}
return timeout
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateMCPDiscoveryTimeout() uses the max init timeout across servers, but Manager.DiscoverTools starts/initializes servers sequentially. With multiple slow servers, total discovery time can exceed max+grace and cause later servers to time out consistently. Consider budgeting for the cumulative expected startup cost (or starting servers concurrently) so multi-server setups don’t fail deterministically.

Copilot uses AI. Check for mistakes.
Comment on lines +449 to +459
err := cmd.Wait()
if waitCh != nil {
close(waitCh)
}
if err != nil {
logger.WarnCF("mcp", "MCP process exited with error",
map[string]any{
"server": serverName,
"error": err.Error(),
})
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waitLoop() logs a warning for any non-nil cmd.Wait() error, but Close() always kills the process; this will typically surface as a non-nil Wait error (e.g., "signal: killed") and produce noisy warnings on normal shutdown. Consider suppressing the warning when the client is being closed intentionally (e.g., check c.closed under lock after Wait).

Copilot uses AI. Check for mistakes.
Comment on lines +574 to +592
func buildProcessEnv(custom map[string]string) []string {
base := os.Environ()
if len(custom) == 0 {
return base
}

keys := make([]string, 0, len(custom))
for key := range custom {
keys = append(keys, key)
}
sort.Strings(keys)

env := make([]string, 0, len(base)+len(keys))
env = append(env, base...)
for _, key := range keys {
env = append(env, key+"="+custom[key])
}
return env
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildProcessEnv() appends custom KEY=VALUE pairs to os.Environ() without removing existing keys, which can leave duplicates. On many platforms getenv() returns the first match, so the custom value may not take effect. Consider filtering base env entries whose keys exist in custom (or constructing a deduped map) so custom values reliably override.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +38
func (t *MCPTool) Name() string {
return t.name
}

func (t *MCPTool) Description() string {
return t.description
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use a getter when you could make the Parameter public?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept fields private to keep tool metadata immutable after construction and avoid accidental mutation from callers. Public fields would expose internal state without reducing interface surface here.

Comment on lines +8 to +17
type callResponse struct {
Content []contentBlock `json:"content"`
StructuredContent any `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
}

type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be moved to types?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept these types local to format.go intentionally because they are parsing-only structs used by one code path (formatCallPayload), and not part of the package contract.

Moving them to types.go would make them look reusable/public API when they are internal implementation details. If we start reusing them across files, I can promote them to shared types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants