Skip to content
Merged
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
52 changes: 21 additions & 31 deletions internal/hub/catalog/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "${HOME}"],
"args": ["-y", "@modelcontextprotocol/server-filesystem@2026.7.10", "${HOME}"],
"setup": "Edit the last argument to the directory you want exposed. Node is required."
},
{
Expand All @@ -18,8 +18,8 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
"author": "Model Context Protocol",
"command": "uvx",
"args": ["mcp-server-fetch"],
"setup": "Needs uv (https://docs.astral.sh/uv/)."
"args": ["--from", "mcp-server-fetch==2026.7.10", "--with", "mcp==1.9.4", "mcp-server-fetch"],
"setup": "Needs uv (https://docs.astral.sh/uv/). Versions are pinned because newer MCP SDK releases break this server."
},
{
"id": "git",
Expand All @@ -29,8 +29,8 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
"author": "Model Context Protocol",
"command": "uvx",
"args": ["mcp-server-git", "--repository", "."],
"setup": "Point --repository at the checkout you want. Needs uv."
"args": ["--from", "mcp-server-git==2026.7.10", "--with", "mcp==1.9.4", "mcp-server-git", "--repository", "."],
"setup": "Point --repository at the checkout you want. Needs uv. Versions are pinned because newer MCP SDK releases break this server."
},
{
"id": "github",
Expand All @@ -53,7 +53,7 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"args": ["-y", "@modelcontextprotocol/server-postgres@0.6.2", "postgresql://localhost/mydb"],
"setup": "Replace the connection string with your own."
},
{
Expand All @@ -64,8 +64,8 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite",
"author": "Model Context Protocol",
"command": "uvx",
"args": ["mcp-server-sqlite", "--db-path", "./data.db"],
"setup": "Point --db-path at your database. Needs uv."
"args": ["--from", "mcp-server-sqlite==2025.4.25", "--with", "mcp==1.9.4", "mcp-server-sqlite", "--db-path", "./data.db"],
"setup": "Point --db-path at your database. Needs uv. Versions are pinned because newer MCP SDK releases break this server."
},
{
"id": "memory",
Expand All @@ -75,7 +75,7 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
"args": ["-y", "@modelcontextprotocol/server-memory@2026.7.4"]
},
{
"id": "sequential-thinking",
Expand All @@ -85,7 +85,7 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking@2026.7.4"]
},
{
"id": "time",
Expand All @@ -95,8 +95,8 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
"author": "Model Context Protocol",
"command": "uvx",
"args": ["mcp-server-time"],
"setup": "Needs uv."
"args": ["--from", "mcp-server-time==2026.7.10", "--with", "mcp==1.23.0", "mcp-server-time"],
"setup": "Needs uv. Versions are pinned because newer MCP SDK releases break older server packages."
},
{
"id": "slack",
Expand All @@ -106,7 +106,7 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/slack",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"args": ["-y", "@modelcontextprotocol/server-slack@2025.4.25"],
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
"needs_keys": ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
"setup": "Create a Slack app, add bot scopes, and install it to the workspace."
Expand All @@ -119,21 +119,11 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"args": ["-y", "@modelcontextprotocol/server-brave-search@0.6.2"],
"env": { "BRAVE_API_KEY": "" },
"needs_keys": ["BRAVE_API_KEY"],
"setup": "Free tier keys at https://brave.com/search/api/."
},
{
"id": "puppeteer",
"name": "Puppeteer",
"summary": "Browser automation through Puppeteer. Antares has its own browser tool; use this when you want the Puppeteer API instead.",
"tags": ["browser", "web"],
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"]
},
{
"id": "notion",
"name": "Notion",
Expand All @@ -142,7 +132,7 @@
"homepage": "https://github.com/makenotion/notion-mcp-server",
"author": "Notion",
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"args": ["-y", "@notionhq/notion-mcp-server@2.5.1"],
"env": { "NOTION_TOKEN": "" },
"needs_keys": ["NOTION_TOKEN"],
"setup": "Create an internal integration at https://www.notion.so/my-integrations and share the pages with it."
Expand All @@ -154,8 +144,8 @@
"tags": ["issues", "productivity"],
"homepage": "https://linear.app/docs/mcp",
"author": "Linear",
"url": "https://mcp.linear.app/sse",
"setup": "A hosted server — it will ask you to authorise on first use."
"url": "https://mcp.linear.app/mcp",
"setup": "Hosted Streamable HTTP server. OAuth is required; bearer-token headers can be configured manually."
},
{
"id": "sentry",
Expand All @@ -164,8 +154,8 @@
"tags": ["observability"],
"homepage": "https://docs.sentry.io/product/sentry-mcp/",
"author": "Sentry",
"url": "https://mcp.sentry.dev/sse",
"setup": "A hosted server — it will ask you to authorise on first use."
"url": "https://mcp.sentry.dev/mcp",
"setup": "Hosted Streamable HTTP server. OAuth is required; bearer-token headers can be configured manually."
},
{
"id": "playwright",
Expand All @@ -175,7 +165,7 @@
"homepage": "https://github.com/microsoft/playwright-mcp",
"author": "Microsoft",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
"args": ["-y", "@playwright/mcp@0.0.79"]
},
{
"id": "everything",
Expand All @@ -185,6 +175,6 @@
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
"author": "Model Context Protocol",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
"args": ["-y", "@modelcontextprotocol/server-everything@2026.7.4"]
}
]
116 changes: 99 additions & 17 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -55,10 +57,11 @@ type rpcRequest struct {

// rpcResponse is a JSON-RPC 2.0 response.
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
transportErr error
}

type rpcError struct {
Expand Down Expand Up @@ -386,7 +389,34 @@ type stdioTransport struct {
// otherwise make every future call wait the full timeout forever. After
// maxConsecutiveTimeouts the transport self-closes so the next caller fails
// fast and the process is reaped. Any successful reply resets it to zero.
timeouts int
timeouts int
stderr stderrCapture
stderrDone chan struct{}
readerErr error
}

type stderrCapture struct {
mu sync.Mutex
buf []byte
}

const maxStderrBytes = 16 << 10

func (c *stderrCapture) append(line string) {
c.mu.Lock()
defer c.mu.Unlock()
c.buf = append(c.buf, line...)
c.buf = append(c.buf, '\n')
if len(c.buf) > maxStderrBytes {
copy(c.buf, c.buf[len(c.buf)-maxStderrBytes:])
c.buf = c.buf[:maxStderrBytes]
}
}

func (c *stderrCapture) String() string {
c.mu.Lock()
defer c.mu.Unlock()
return strings.TrimSpace(string(c.buf))
}

// maxConsecutiveTimeouts is how many back-to-back ctx.Done timeouts a stdio
Expand All @@ -397,7 +427,7 @@ func newStdioTransport(cfg ServerConfig) (transport, error) {
if strings.TrimSpace(cfg.Command) == "" {
return nil, fmt.Errorf("stdio transport needs a command")
}
cmd := exec.Command(cfg.Command, cfg.Args...)
cmd := exec.Command(cfg.Command, expandArgs(cfg.Args)...)
cmd.Env = os.Environ()
for k, v := range cfg.Env {
cmd.Env = append(cmd.Env, k+"="+v)
Expand All @@ -419,26 +449,48 @@ func newStdioTransport(cfg ServerConfig) (transport, error) {
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start %s: %w", cfg.Command, err)
}
go func() {
sc := bufio.NewScanner(stderr)
for sc.Scan() {
slog.Debug("mcp server stderr", "command", cfg.Command, "line", sc.Text())
}
}()

t := &stdioTransport{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewReaderSize(stdout, 1<<20),
pending: map[int64]chan *rpcResponse{},
readerDone: make(chan struct{}),
stderrDone: make(chan struct{}),
}
go func() {
defer close(t.stderrDone)
sc := bufio.NewScanner(stderr)
for sc.Scan() {
t.stderr.append(sc.Text())
slog.Debug("mcp server stderr", "command", cfg.Command, "line", sc.Text())
}
}()
// Reap the child if it exits on its own so it never sits as a zombie until
// the next Close/Refresh. Wait is idempotent via waitOnce.
go func() { _ = t.reap() }()
return t, nil
}

func expandArgs(args []string) []string {
home, _ := os.UserHomeDir()
out := make([]string, len(args))
for i, arg := range args {
arg = os.Expand(arg, func(key string) string {
if key == "HOME" && os.Getenv(key) == "" {
return home
}
return os.Getenv(key)
})
if arg == "~" {
arg = home
} else if strings.HasPrefix(arg, "~/") || strings.HasPrefix(arg, `~\`) {
arg = filepath.Join(home, arg[2:])
}
out[i] = arg
}
return out
}

func (t *stdioTransport) reap() error {
t.waitOnce.Do(func() {
if t.cmd != nil {
Expand Down Expand Up @@ -496,21 +548,41 @@ func (t *stdioTransport) startReader() {
}

// failPending delivers an error to every waiting caller and clears the map.
func (t *stdioTransport) failPending(err error) {
func (t *stdioTransport) failPending(readErr error) {
err := t.processError(readErr)
t.pendingMu.Lock()
t.readerErr = err
for id, ch := range t.pending {
// Non-blocking: the caller may have already returned on ctx.Done and
// stopped reading. The channel is buffered(1), so a live caller still
// receives this; an abandoned one must not wedge the reader goroutine.
select {
case ch <- &rpcResponse{Error: &rpcError{Message: err.Error()}}:
case ch <- &rpcResponse{transportErr: err}:
default:
}
delete(t.pending, id)
}
t.pendingMu.Unlock()
}

func (t *stdioTransport) processError(readErr error) error {
waitErr := t.reap()
<-t.stderrDone
stderr := t.stderr.String()
switch {
case waitErr != nil && stderr != "":
return fmt.Errorf("MCP server exited (%v): %s", waitErr, stderr)
case waitErr != nil:
return fmt.Errorf("MCP server exited: %w", waitErr)
case stderr != "":
return fmt.Errorf("MCP server closed stdout: %s", stderr)
case readErr != nil && !errors.Is(readErr, io.EOF):
return fmt.Errorf("MCP server output failed: %w", readErr)
default:
return errors.New("MCP server exited before replying")
}
}

func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse, error) {
// One in-flight request at a time — required for line-delimited stdio.
t.sendMu.Lock()
Expand Down Expand Up @@ -563,9 +635,19 @@ func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse
}
return nil, ctx.Err()
case <-t.readerDone:
// The background reader exited (EOF, child died). Surface the failure.
return nil, fmt.Errorf("mcp connection lost")
// The background reader exited (EOF, child died). Surface its exit status
// and bounded stderr tail instead of reducing every startup crash to EOF.
t.pendingMu.Lock()
err := t.readerErr
t.pendingMu.Unlock()
if err == nil {
err = errors.New("MCP server connection lost")
}
return nil, err
case r := <-ch:
if r.transportErr != nil {
return nil, r.transportErr
}
t.pendingMu.Lock()
t.timeouts = 0
t.pendingMu.Unlock()
Expand Down
Loading
Loading