1733 lines
54 KiB
Go
1733 lines
54 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// THE TOOL SERVERS THIS MACHINE'S AGENT CLIENTS ARE SET UP TO START.
|
|
//
|
|
// Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Codex and Zed each
|
|
// keep a file that lists the tool servers (Model Context Protocol servers) the
|
|
// client may start or connect to. An assessment needs that list: which servers
|
|
// a person's agents can reach, how each is launched, whether a secret sits in
|
|
// the file in plain text, and whether the server the file names is actually
|
|
// running — or a server is running that no file names.
|
|
//
|
|
// THIS FILE ONLY READS FILES. It opens no socket, starts no process and never
|
|
// talks to a server; the bastion does not speak that protocol, and
|
|
// TestNoRouteOpOrDependencySpeaksMCP (claims_static_test.go) holds it to that
|
|
// by walking the call graph out of every function declared here. The process
|
|
// list it correlates against is handed IN by agents.go, which reads it with
|
|
// the platform's own enumerator (scanMCPProcesses, agents_<goos>.go).
|
|
//
|
|
// WHAT LEAVES THE MACHINE, AND WHAT NEVER DOES. Per declared server: the
|
|
// client, the config scope, the file path, the server's name, transport,
|
|
// whether it is a local process or a URL (and the URL's host), the launch
|
|
// command truncated the way agent command lines are, the package and whether
|
|
// it is pinned, the NAMES of the environment variables it is given, whether a
|
|
// value matched a secret's shape, whether a remote server has authentication
|
|
// configured, and whether it is running. NEVER a value of an environment
|
|
// variable or a header: a value is looked at once, for its shape, and dropped
|
|
// in the same function. A command-line argument that looks like a secret is
|
|
// replaced by a marker before the command is kept at all. Paths and names
|
|
// travel only inside the heartbeat's `observing` gate, beside the agent list.
|
|
//
|
|
// WHERE EACH CLIENT KEEPS ITS FILE is mcpConfigSources below, with the page
|
|
// each path was checked against on 2026-10-20. Paths a page did not state are
|
|
// marked there as not confirmed; they are still read, because reading a file
|
|
// that is not there costs a stat and finds nothing.
|
|
|
|
// mcpServerObs is one declared (or running, undeclared) tool server.
|
|
type mcpServerObs struct {
|
|
Client string `json:"client,omitempty"` // claude-desktop, claude-code, cursor, vscode, windsurf, codex, zed
|
|
Scope string `json:"scope,omitempty"` // user, project, system
|
|
Path string `json:"path,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
// Transport: stdio, http, sse, websocket, unknown.
|
|
Transport string `json:"transport"`
|
|
// Locality: local_process (the client starts it here), loopback, lan,
|
|
// remote (a URL host off this network), unknown.
|
|
Locality string `json:"locality"`
|
|
URLHost string `json:"url_host,omitempty"`
|
|
// Command is the launch command and its arguments, joined and truncated
|
|
// to 200 characters like an agent's cmdline, with any argument that
|
|
// looked like a secret replaced by mcpRedacted first.
|
|
Command string `json:"command,omitempty"`
|
|
// PackageSource: npx, uvx, pip, docker, binary, unknown.
|
|
PackageSource string `json:"package_source,omitempty"`
|
|
PackageName string `json:"package_name,omitempty"`
|
|
// PackagePinned: yes (an exact version or an image digest), no (a
|
|
// range, a tag, or nothing), unknown (a path or binary).
|
|
PackagePinned string `json:"package_pinned,omitempty"`
|
|
EnvNames []string `json:"env_names,omitempty"`
|
|
// SecretInPlaintext: a value in this entry (an env value, a header, an
|
|
// argument, the URL) matched a secret's shape. The value is not kept.
|
|
SecretInPlaintext bool `json:"secret_in_plaintext"`
|
|
// AuthConfigured: yes when the file gives a remote server a credential
|
|
// (a header, a bearer-token variable, credentials in the URL);
|
|
// not_applicable for a local process; unknown otherwise, because a client
|
|
// may negotiate OAuth when it connects and that leaves nothing in a file.
|
|
AuthConfigured string `json:"auth_configured"`
|
|
// BindClass: what the server listens on. not_applicable for stdio;
|
|
// unknown for a URL, which says where the client connects, not what the
|
|
// server binds.
|
|
BindClass string `json:"bind_class"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
// RunningState: declared_running, declared_not_running,
|
|
// running_not_declared; not_local for a URL off this machine; unknown
|
|
// when the process list could not be read or a loopback URL cannot be
|
|
// matched to a process from a file.
|
|
RunningState string `json:"running_state"`
|
|
PIDs []int `json:"pids,omitempty"`
|
|
|
|
// The correlation key, never sent: a package name or a script path to
|
|
// find in a command line, or a program's basename to find as argv[0].
|
|
matchSub string
|
|
matchBase string
|
|
}
|
|
|
|
// mcpConfigScan says how much was looked at, so "no servers" from a box that
|
|
// read nothing is visibly different from one that read forty files.
|
|
type mcpConfigScan struct {
|
|
Supported bool `json:"supported"`
|
|
Accounts int `json:"accounts"`
|
|
Projects int `json:"projects"`
|
|
FilesRead int `json:"files_read"`
|
|
Unreadable int `json:"files_unreadable"`
|
|
Unparsed int `json:"files_unparsed"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
// ForeignOwner: files skipped because neither the account they were
|
|
// read for nor root (SYSTEM or Administrators on Windows) owns them
|
|
// (mcpReadFileAs).
|
|
ForeignOwner int `json:"files_foreign_owner,omitempty"`
|
|
// TimedOut: the file read did not finish within mcpReadTimeout; the
|
|
// list is the last complete read, or empty when there was none.
|
|
TimedOut bool `json:"timed_out,omitempty"`
|
|
// ProcessScan: ok, failed or unsupported — whether running_state could
|
|
// be measured at all.
|
|
ProcessScan string `json:"process_scan"`
|
|
}
|
|
|
|
const (
|
|
mcpRedacted = "<redacted>"
|
|
mcpUnknown = "unknown"
|
|
mcpMaxFileBytes = 1 << 20
|
|
mcpMaxAccounts = 64
|
|
mcpMaxProjects = 200
|
|
mcpMaxServers = 500
|
|
mcpMaxPIDs = 8
|
|
mcpCommandMax = 200
|
|
mcpNameMax = 128 // a server name, an env name, a package name
|
|
mcpHostMax = 253 // a DNS name's own limit
|
|
mcpPathMax = 1024
|
|
mcpMaxEnvNames = 32
|
|
mcpMaxNesting = 64 // TOML arrays and inline tables
|
|
mcpNoOwnerCheck = "" // the owner a file is read for when no owner check applies
|
|
mcpOwnerUnknown = "?" // an owner that could not be read: matches nothing
|
|
mcpStateRunning = "declared_running"
|
|
mcpStateIdle = "declared_not_running"
|
|
mcpStateShadow = "running_not_declared"
|
|
mcpStateNotLocal = "not_local"
|
|
mcpStateUnknown = "unknown"
|
|
mcpLocalProcess = "local_process"
|
|
mcpNotApplicable = "not_applicable"
|
|
mcpFormatServers = "mcpServers" // a top-level mcpServers object
|
|
mcpFormatClaude = "claude-json" // ~/.claude.json: user and per-project servers
|
|
mcpFormatVSCode = "vscode" // a top-level servers object
|
|
mcpFormatZed = "context_servers" // Zed settings, JSON with comments
|
|
mcpFormatCodex = "codex-toml" // [mcp_servers.<name>] tables
|
|
mcpScopeUser = "user"
|
|
mcpScopeProject = "project"
|
|
mcpScopeSystem = "system"
|
|
mcpClientDesktop = "claude-desktop"
|
|
mcpClientCode = "claude-code"
|
|
mcpClientCursor = "cursor"
|
|
mcpClientVSCode = "vscode"
|
|
mcpClientWindsurf = "windsurf"
|
|
mcpClientCodex = "codex"
|
|
mcpClientZed = "zed"
|
|
)
|
|
|
|
// mcpHome is one account's home directory and the per-user application
|
|
// configuration directory under it (XDG on Linux, Application Support on
|
|
// macOS, AppData\Roaming on Windows).
|
|
type mcpHome struct {
|
|
Dir string
|
|
ConfigDir string
|
|
}
|
|
|
|
// mcpSource is one file to read.
|
|
type mcpSource struct {
|
|
Client, Scope, Path, Format string
|
|
}
|
|
|
|
// mcpConfigSources is every file the seven clients keep for one account, for
|
|
// the platform goos. Checked against each vendor's page on 2026-10-20:
|
|
//
|
|
// claude-desktop https://modelcontextprotocol.io/docs/develop/connect-local-servers
|
|
// macOS and Windows stated; the Linux path is NOT CONFIRMED
|
|
// (the page says Linux is supported and gives no path).
|
|
// claude-code https://code.claude.com/docs/en/mcp — user and local scope in
|
|
// ~/.claude.json (local nested under projects.<path>), project
|
|
// scope in .mcp.json; https://code.claude.com/docs/en/managed-mcp
|
|
// for the system file on each platform.
|
|
// cursor https://cursor.com/docs/context/mcp — ~/.cursor/mcp.json and
|
|
// .cursor/mcp.json; given only in the ~ form, so the Windows
|
|
// location (the user profile) is NOT CONFIRMED.
|
|
// vscode https://code.visualstudio.com/docs/copilot/reference/mcp-configuration
|
|
// — .vscode/mcp.json, and an mcp.json "in your user profile";
|
|
// the profile folder is from
|
|
// https://code.visualstudio.com/docs/configure/profiles. The
|
|
// exact user-file path is NOT CONFIRMED on any platform.
|
|
// windsurf https://docs.devin.ai/windsurf/plugins/cascade/mcp — the
|
|
// config-directory file (devin/mcp_config.json) and the
|
|
// editor's own ~/.codeium/windsurf[-next]/mcp_config.json; the
|
|
// Windows form of the second is NOT CONFIRMED.
|
|
// codex https://learn.chatgpt.com/docs/config-file/config-basic and
|
|
// https://learn.chatgpt.com/docs/extend/mcp — ~/.codex/config.toml,
|
|
// /etc/codex/config.toml, and .codex/config.toml in a trusted
|
|
// project; the Windows path is NOT CONFIRMED.
|
|
// zed https://zed.dev/docs/configuring-zed — ~/.config/zed/settings.json
|
|
// (macOS and Linux), %APPDATA%\Zed\settings.json, and
|
|
// .zed/settings.json in a project; "JSON with support for //
|
|
// comments".
|
|
func mcpConfigSources(goos string, h mcpHome) []mcpSource {
|
|
j := filepath.Join
|
|
out := []mcpSource{
|
|
{mcpClientDesktop, mcpScopeUser, j(h.ConfigDir, "Claude", "claude_desktop_config.json"), mcpFormatServers},
|
|
{mcpClientCode, mcpScopeUser, j(h.Dir, ".claude.json"), mcpFormatClaude},
|
|
{mcpClientCursor, mcpScopeUser, j(h.Dir, ".cursor", "mcp.json"), mcpFormatServers},
|
|
{mcpClientVSCode, mcpScopeUser, j(h.ConfigDir, "Code", "User", "mcp.json"), mcpFormatVSCode},
|
|
{mcpClientWindsurf, mcpScopeUser, j(h.ConfigDir, "devin", "mcp_config.json"), mcpFormatServers},
|
|
{mcpClientWindsurf, mcpScopeUser, j(h.Dir, ".codeium", "windsurf", "mcp_config.json"), mcpFormatServers},
|
|
{mcpClientWindsurf, mcpScopeUser, j(h.Dir, ".codeium", "windsurf-next", "mcp_config.json"), mcpFormatServers},
|
|
{mcpClientCodex, mcpScopeUser, j(h.Dir, ".codex", "config.toml"), mcpFormatCodex},
|
|
}
|
|
if goos == "windows" {
|
|
out = append(out, mcpSource{mcpClientZed, mcpScopeUser, j(h.ConfigDir, "Zed", "settings.json"), mcpFormatZed})
|
|
} else {
|
|
// macOS and Linux both: Zed keeps ~/.config/zed on macOS too, not
|
|
// Application Support.
|
|
out = append(out, mcpSource{mcpClientZed, mcpScopeUser, j(h.Dir, ".config", "zed", "settings.json"), mcpFormatZed})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mcpSystemSources is the machine-wide files: Claude Code's managed file and
|
|
// Codex's system config. programFiles is %ProgramFiles% on Windows.
|
|
func mcpSystemSources(goos, programFiles string) []mcpSource {
|
|
switch goos {
|
|
case "darwin":
|
|
return []mcpSource{
|
|
{mcpClientCode, mcpScopeSystem, filepath.Join("/Library", "Application Support", "ClaudeCode", "managed-mcp.json"), mcpFormatServers},
|
|
{mcpClientCodex, mcpScopeSystem, filepath.Join("/etc", "codex", "config.toml"), mcpFormatCodex},
|
|
}
|
|
case "windows":
|
|
if programFiles == "" {
|
|
return nil
|
|
}
|
|
return []mcpSource{
|
|
{mcpClientCode, mcpScopeSystem, filepath.Join(programFiles, "ClaudeCode", "managed-mcp.json"), mcpFormatServers},
|
|
}
|
|
default:
|
|
return []mcpSource{
|
|
{mcpClientCode, mcpScopeSystem, filepath.Join("/etc", "claude-code", "managed-mcp.json"), mcpFormatServers},
|
|
{mcpClientCodex, mcpScopeSystem, filepath.Join("/etc", "codex", "config.toml"), mcpFormatCodex},
|
|
}
|
|
}
|
|
}
|
|
|
|
// mcpProjectSources is the per-project files under one project directory.
|
|
// Project directories come from Claude Code's own list of projects in
|
|
// ~/.claude.json; the disk is never walked looking for projects, so a
|
|
// project only another client has opened is not seen. That is a stated
|
|
// limit, not a finding of "none".
|
|
func mcpProjectSources(dir string) []mcpSource {
|
|
j := filepath.Join
|
|
return []mcpSource{
|
|
{mcpClientCode, mcpScopeProject, j(dir, ".mcp.json"), mcpFormatServers},
|
|
{mcpClientCursor, mcpScopeProject, j(dir, ".cursor", "mcp.json"), mcpFormatServers},
|
|
{mcpClientVSCode, mcpScopeProject, j(dir, ".vscode", "mcp.json"), mcpFormatVSCode},
|
|
{mcpClientZed, mcpScopeProject, j(dir, ".zed", "settings.json"), mcpFormatZed},
|
|
{mcpClientCodex, mcpScopeProject, j(dir, ".codex", "config.toml"), mcpFormatCodex},
|
|
}
|
|
}
|
|
|
|
// mcpGOOS is the platform whose paths are read; a test reads another's.
|
|
var mcpGOOS = runtime.GOOS
|
|
|
|
// mcpHomesFn and mcpSystemFn are the platform's account list and system files
|
|
// (mcpconfig_<goos>.go); a test points them at a fixture.
|
|
var (
|
|
mcpHomesFn = mcpHomes
|
|
mcpSystemFn = mcpSystemFiles
|
|
)
|
|
|
|
// readMCPConfigs reads every source for every account and returns the
|
|
// declared servers (running state not yet known) and what was looked at.
|
|
func readMCPConfigs() ([]mcpServerObs, mcpConfigScan) {
|
|
homes, supported := mcpHomesFn()
|
|
scan := mcpConfigScan{Supported: supported}
|
|
if !supported {
|
|
return nil, scan
|
|
}
|
|
if len(homes) > mcpMaxAccounts {
|
|
homes, scan.Truncated = homes[:mcpMaxAccounts], true
|
|
}
|
|
scan.Accounts = len(homes)
|
|
var out []mcpServerObs
|
|
// A project is read for the account whose list named it, and its files
|
|
// are held to that account's ownership: one account naming another's
|
|
// directory as a project reads nothing of the other's.
|
|
type project struct {
|
|
dir string
|
|
owner string
|
|
}
|
|
seenProject := map[project]bool{}
|
|
var projects []project
|
|
read := func(src mcpSource, owner string) {
|
|
raw, ok := mcpReadFileAs(src.Path, owner, &scan)
|
|
if !ok {
|
|
return
|
|
}
|
|
entries, projs, err := mcpParse(src.Format, raw)
|
|
if err != nil {
|
|
scan.Unparsed++
|
|
return
|
|
}
|
|
for _, d := range projs {
|
|
p := project{d, owner}
|
|
if !seenProject[p] {
|
|
seenProject[p] = true
|
|
projects = append(projects, p)
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
o := mcpAnalyze(e)
|
|
o.Client, o.Path = src.Client, truncate(src.Path, mcpPathMax)
|
|
o.Scope = src.Scope
|
|
if e.projectScoped {
|
|
o.Scope = mcpScopeProject
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
// The system files sit where only an administrator can write.
|
|
for _, src := range mcpSystemFn() {
|
|
read(src, mcpNoOwnerCheck)
|
|
}
|
|
for _, h := range homes {
|
|
owner := mcpPathOwner(h.Dir)
|
|
for _, src := range mcpConfigSources(mcpGOOS, h) {
|
|
read(src, owner)
|
|
}
|
|
}
|
|
sort.Slice(projects, func(i, j int) bool {
|
|
if projects[i].dir != projects[j].dir {
|
|
return projects[i].dir < projects[j].dir
|
|
}
|
|
return projects[i].owner < projects[j].owner
|
|
})
|
|
if len(projects) > mcpMaxProjects {
|
|
projects, scan.Truncated = projects[:mcpMaxProjects], true
|
|
}
|
|
scan.Projects = len(projects)
|
|
for _, p := range projects {
|
|
for _, src := range mcpProjectSources(p.dir) {
|
|
read(src, p.owner)
|
|
}
|
|
}
|
|
if len(out) > mcpMaxServers {
|
|
out, scan.Truncated = out[:mcpMaxServers], true
|
|
}
|
|
return out, scan
|
|
}
|
|
|
|
// mcpReadFile reads one config file, bounded. A file that is not there is
|
|
// not counted; one that is there and cannot be read is, so a bastion without
|
|
// the right to look says so instead of reporting "no servers".
|
|
//
|
|
// On linux and darwin a symlink is followed, as the client itself would, but
|
|
// what it points at is OPENED only once it is known to be a regular file
|
|
// (mcpOpenRegular, mcpconfig_open_<goos>.go). On Windows a link at the path
|
|
// itself is refused (mcpconfig_winopen.go). These paths sit in other people's home directories
|
|
// and this service reads them with every right it has: opening a FIFO would
|
|
// hold the heartbeat, and opening a device runs its driver (a watchdog arms,
|
|
// a serial line toggles), so a crafted link must never get that far.
|
|
func mcpReadFile(path string, scan *mcpConfigScan) ([]byte, bool) {
|
|
return mcpReadFileAs(path, mcpNoOwnerCheck, scan)
|
|
}
|
|
|
|
// mcpReadFileAs is mcpReadFile for one account: the file is read only when
|
|
// that account or an administrative owner owns it (root; on Windows SYSTEM
|
|
// or the Administrators group), so an account cannot point its own config
|
|
// path at another's file and have this service read it for it. owner is a
|
|
// uid on unix and a SID on Windows; mcpNoOwnerCheck reads whatever is there
|
|
// (the system files; a platform with no owner model).
|
|
func mcpReadFileAs(path string, owner string, scan *mcpConfigScan) ([]byte, bool) {
|
|
f, fileOwner, err := mcpOpenRegularFn(path)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
scan.Unreadable++
|
|
}
|
|
return nil, false
|
|
}
|
|
defer f.Close()
|
|
if !mcpOwnerAllowed(fileOwner, owner, mcpAdminOwners) {
|
|
scan.ForeignOwner++
|
|
return nil, false
|
|
}
|
|
b, err := io.ReadAll(io.LimitReader(f, mcpMaxFileBytes+1))
|
|
if err != nil {
|
|
scan.Unreadable++
|
|
return nil, false
|
|
}
|
|
if len(b) > mcpMaxFileBytes {
|
|
scan.Unparsed++
|
|
return nil, false
|
|
}
|
|
scan.FilesRead++
|
|
return b, true
|
|
}
|
|
|
|
// errMCPNotRegular: the path names something other than a regular file.
|
|
var errMCPNotRegular = errors.New("not a regular file")
|
|
|
|
// mcpOpenRegularFn is the seam a test uses to stand a platform's open in;
|
|
// production never reassigns it.
|
|
var mcpOpenRegularFn = mcpOpenRegular
|
|
|
|
// mcpOwnerAllowed: a file owned by file may be read for want. An owner that
|
|
// could not be read is never allowed where a check applies.
|
|
func mcpOwnerAllowed(file, want string, admins map[string]bool) bool {
|
|
if want == mcpNoOwnerCheck {
|
|
return true
|
|
}
|
|
if file == "" || file == mcpOwnerUnknown {
|
|
return false
|
|
}
|
|
return file == want || admins[file]
|
|
}
|
|
|
|
// mcpEntry is one server as a file declares it, before analysis. Values live
|
|
// here only between parsing and mcpAnalyze.
|
|
type mcpEntry struct {
|
|
name string
|
|
typ string
|
|
command string
|
|
args []string
|
|
env map[string]string
|
|
envNames []string // names passed through without a value (Codex env_vars)
|
|
url string
|
|
headers map[string]string
|
|
authHint bool // a credential configured by reference: bearer_token_env_var, env_http_headers, a headers helper
|
|
disabled bool
|
|
projectScoped bool
|
|
}
|
|
|
|
// mcpParse turns one file into entries, plus any project directories the
|
|
// file lists (Claude Code's ~/.claude.json).
|
|
func mcpParse(format string, raw []byte) ([]mcpEntry, []string, error) {
|
|
if format == mcpFormatCodex {
|
|
t, err := parseTOMLSubset(string(raw))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return mcpCodexEntries(t), nil, nil
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(stripJSONC(raw), &doc); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
switch format {
|
|
case mcpFormatVSCode:
|
|
return mcpJSONEntries(doc["servers"], false), nil, nil
|
|
case mcpFormatZed:
|
|
return mcpJSONEntries(doc["context_servers"], false), nil, nil
|
|
case mcpFormatClaude:
|
|
out := mcpJSONEntries(doc["mcpServers"], false)
|
|
var projects []string
|
|
if ps, ok := doc["projects"].(map[string]any); ok {
|
|
for dir, v := range ps {
|
|
projects = append(projects, filepath.FromSlash(dir))
|
|
if p, ok := v.(map[string]any); ok {
|
|
out = append(out, mcpJSONEntries(p["mcpServers"], true)...)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(projects)
|
|
return out, projects, nil
|
|
default:
|
|
return mcpJSONEntries(doc["mcpServers"], false), nil, nil
|
|
}
|
|
}
|
|
|
|
// mcpJSONEntries reads one servers object. The clients agree on command,
|
|
// args, env, url and headers; type is spelled `type` (and `transport` by some
|
|
// older files); Windsurf also spells the URL serverUrl; Zed's older form nests
|
|
// the command as {path, args, env}.
|
|
func mcpJSONEntries(v any, projectScoped bool) []mcpEntry {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(m))
|
|
for k := range m {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
var out []mcpEntry
|
|
for _, name := range names {
|
|
s, ok := m[name].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
e := mcpEntry{name: name, projectScoped: projectScoped,
|
|
typ: jsonStr(s["type"]), url: jsonStr(s["url"])}
|
|
if e.typ == "" {
|
|
e.typ = jsonStr(s["transport"])
|
|
}
|
|
if e.url == "" {
|
|
e.url = jsonStr(s["serverUrl"])
|
|
}
|
|
e.args = jsonStrList(s["args"])
|
|
e.env = jsonStrMap(s["env"])
|
|
switch c := s["command"].(type) {
|
|
case string:
|
|
e.command = c
|
|
case map[string]any:
|
|
e.command = jsonStr(c["path"])
|
|
e.args = append(e.args, jsonStrList(c["args"])...)
|
|
for k, v := range jsonStrMap(c["env"]) {
|
|
if e.env == nil {
|
|
e.env = map[string]string{}
|
|
}
|
|
e.env[k] = v
|
|
}
|
|
}
|
|
e.headers = jsonStrMap(s["headers"])
|
|
if s["headersHelper"] != nil || s["oauth"] != nil {
|
|
e.authHint = true
|
|
}
|
|
if d, ok := s["disabled"].(bool); ok && d {
|
|
e.disabled = true
|
|
}
|
|
if en, ok := s["enabled"].(bool); ok && !en {
|
|
e.disabled = true
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func jsonStr(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func jsonStrList(v any) []string {
|
|
l, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(l))
|
|
for _, x := range l {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// jsonStrMap keeps string values; any other value is kept as "" so its NAME
|
|
// still counts (a number in env is still a variable passed to the server).
|
|
func jsonStrMap(v any) map[string]string {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(m))
|
|
for k, x := range m {
|
|
s, _ := x.(string)
|
|
out[k] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mcpCodexEntries reads [mcp_servers.<name>] tables (Codex config.toml):
|
|
// command, args, env, env_vars, url, bearer_token_env_var, http_headers,
|
|
// env_http_headers, enabled.
|
|
func mcpCodexEntries(t map[string]any) []mcpEntry {
|
|
servers, ok := t["mcp_servers"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(servers))
|
|
for k := range servers {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
var out []mcpEntry
|
|
for _, name := range names {
|
|
s, ok := servers[name].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
e := mcpEntry{name: name, command: jsonStr(s["command"]), url: jsonStr(s["url"]),
|
|
args: jsonStrList(s["args"]), env: jsonStrMap(s["env"]), headers: jsonStrMap(s["http_headers"])}
|
|
if l, ok := s["env_vars"].([]any); ok {
|
|
for _, x := range l {
|
|
switch v := x.(type) {
|
|
case string:
|
|
e.envNames = append(e.envNames, v)
|
|
case map[string]any:
|
|
if n := jsonStr(v["name"]); n != "" {
|
|
e.envNames = append(e.envNames, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if n := jsonStr(s["bearer_token_env_var"]); n != "" {
|
|
e.authHint = true
|
|
e.envNames = append(e.envNames, n)
|
|
}
|
|
if h, ok := s["env_http_headers"].(map[string]any); ok && len(h) > 0 {
|
|
e.authHint = true
|
|
}
|
|
if en, ok := s["enabled"].(bool); ok && !en {
|
|
e.disabled = true
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mcpAnalyze turns an entry into the record that leaves the machine. Every
|
|
// value it looks at is used for a shape check and then goes out of scope with
|
|
// the entry; nothing but names and classes is copied into the result.
|
|
func mcpAnalyze(e mcpEntry) mcpServerObs {
|
|
o := mcpServerObs{Name: truncate(e.name, mcpNameMax), Disabled: e.disabled}
|
|
o.Transport = mcpTransport(e)
|
|
for k := range e.env {
|
|
o.EnvNames = append(o.EnvNames, k)
|
|
}
|
|
o.EnvNames = append(o.EnvNames, e.envNames...)
|
|
o.EnvNames = dedupeStrings(o.EnvNames)
|
|
sort.Strings(o.EnvNames)
|
|
if len(o.EnvNames) > mcpMaxEnvNames {
|
|
o.EnvNames = o.EnvNames[:mcpMaxEnvNames]
|
|
}
|
|
for i, n := range o.EnvNames {
|
|
o.EnvNames[i] = truncate(n, mcpNameMax)
|
|
}
|
|
for k, v := range e.env {
|
|
if secretShaped(k, v) {
|
|
o.SecretInPlaintext = true
|
|
}
|
|
}
|
|
headerAuth := false
|
|
for k, v := range e.headers {
|
|
if secretShaped(k, v) {
|
|
o.SecretInPlaintext = true
|
|
}
|
|
if mcpAuthHeader(k) {
|
|
headerAuth = true
|
|
}
|
|
}
|
|
args, argSecret := redactSecretArgs(e.args)
|
|
if argSecret {
|
|
o.SecretInPlaintext = true
|
|
}
|
|
if e.command != "" {
|
|
// A file may put the whole command line in command; it is redacted
|
|
// word by word like the arguments.
|
|
cmd, cmdSecret := redactSecretArgs(strings.Fields(e.command))
|
|
if cmdSecret {
|
|
o.SecretInPlaintext = true
|
|
}
|
|
o.Command = truncate(strings.Join(append(cmd, args...), " "), mcpCommandMax)
|
|
o.PackageSource, o.PackageName, o.PackagePinned = mcpPackage(e.command, e.args)
|
|
o.matchSub, o.matchBase = mcpMatchKey(e.command, e.args, o.PackageName)
|
|
o.PackageName = truncate(o.PackageName, mcpNameMax)
|
|
}
|
|
if o.Transport == "stdio" {
|
|
o.Locality, o.AuthConfigured, o.BindClass = mcpLocalProcess, mcpNotApplicable, mcpNotApplicable
|
|
return o
|
|
}
|
|
o.BindClass = mcpUnknown
|
|
o.AuthConfigured = mcpUnknown
|
|
o.Locality = mcpUnknown
|
|
if e.url != "" {
|
|
host, urlSecret, urlAuth := mcpURLFacts(e.url)
|
|
o.URLHost = truncate(host, mcpHostMax)
|
|
o.Locality = hostLocality(host)
|
|
if urlSecret {
|
|
o.SecretInPlaintext = true
|
|
}
|
|
if urlAuth {
|
|
headerAuth = true
|
|
}
|
|
}
|
|
if headerAuth || e.authHint {
|
|
o.AuthConfigured = ownerYes
|
|
}
|
|
return o
|
|
}
|
|
|
|
// mcpTransport is the declared type, or what the entry's shape implies.
|
|
func mcpTransport(e mcpEntry) string {
|
|
switch strings.ToLower(strings.TrimSpace(e.typ)) {
|
|
case "stdio":
|
|
return "stdio"
|
|
case "http", "streamable-http", "streamablehttp", "streamable_http":
|
|
return "http"
|
|
case "sse":
|
|
return "sse"
|
|
case "ws", "websocket":
|
|
return "websocket"
|
|
case "":
|
|
default:
|
|
return mcpUnknown
|
|
}
|
|
switch {
|
|
case e.command != "":
|
|
return "stdio"
|
|
case e.url == "":
|
|
return mcpUnknown
|
|
}
|
|
u, err := url.Parse(e.url)
|
|
if err != nil {
|
|
return mcpUnknown
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "ws", "wss":
|
|
return "websocket"
|
|
}
|
|
if strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/sse") {
|
|
return "sse"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
// mcpURLFacts: the URL's host, whether credentials sit in it (a password in
|
|
// the userinfo, or a secret-named query parameter with a value), and whether
|
|
// that amounts to authentication being configured. The URL itself is not
|
|
// kept: its path and query can carry a token.
|
|
func mcpURLFacts(raw string) (host string, secret bool, auth bool) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", false, false
|
|
}
|
|
host = strings.ToLower(u.Hostname())
|
|
if u.User != nil {
|
|
if _, has := u.User.Password(); has {
|
|
secret, auth = true, true
|
|
}
|
|
}
|
|
for k, vs := range u.Query() {
|
|
for _, v := range vs {
|
|
if secretName(k) && v != "" && !isReference(v) {
|
|
secret, auth = true, true
|
|
}
|
|
}
|
|
}
|
|
return host, secret, auth
|
|
}
|
|
|
|
// hostLocality classes a URL host without resolving it. Resolving a name is
|
|
// a network request, which this file does not make, so a name is classed
|
|
// by its form: loopback names and addresses, private and link-local
|
|
// addresses, and names no public resolver answers (one label, .local,
|
|
// .internal, .lan, .home.arpa) are local; anything else is remote.
|
|
func hostLocality(host string) string {
|
|
h := strings.TrimSuffix(strings.ToLower(host), ".")
|
|
switch {
|
|
case h == "":
|
|
return mcpUnknown
|
|
case h == "localhost" || strings.HasSuffix(h, ".localhost"):
|
|
return "loopback"
|
|
}
|
|
if ip := net.ParseIP(h); ip != nil {
|
|
switch {
|
|
case ip.IsLoopback():
|
|
return "loopback"
|
|
case ip.IsPrivate() || ip.IsLinkLocalUnicast():
|
|
return "lan"
|
|
case ip.IsUnspecified():
|
|
return "loopback"
|
|
}
|
|
return "remote"
|
|
}
|
|
if !strings.Contains(h, ".") {
|
|
return "lan"
|
|
}
|
|
for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa"} {
|
|
if strings.HasSuffix(h, suffix) {
|
|
return "lan"
|
|
}
|
|
}
|
|
return "remote"
|
|
}
|
|
|
|
// mcpAuthHeader: a header whose name carries a credential.
|
|
func mcpAuthHeader(name string) bool {
|
|
n := strings.ToLower(name)
|
|
return n == "authorization" || n == "cookie" || n == "proxy-authorization" || secretName(n)
|
|
}
|
|
|
|
// ── secrets: the shape, never the value ─────────────────────────────────────
|
|
|
|
// secretPrefixes are the shapes of credentials whose issuers publish a fixed
|
|
// prefix: Anthropic and OpenAI keys, GitHub tokens, Slack tokens, AWS access
|
|
// key ids, Google API keys, GitLab tokens, Hugging Face and npm tokens, JWTs,
|
|
// and PEM private keys.
|
|
var secretPrefixes = regexp.MustCompile(`(?:^|[^A-Za-z0-9])(?:` +
|
|
`sk-ant-[A-Za-z0-9_-]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|` +
|
|
`gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|` +
|
|
`xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|` +
|
|
`glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|npm_[A-Za-z0-9]{36}|` +
|
|
`eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.|-----BEGIN [A-Z ]*PRIVATE KEY-----)`)
|
|
|
|
// secretNameRe: a variable, header or flag whose name says it holds a secret.
|
|
var secretNameRe = regexp.MustCompile(`(?i)(key|token|secret|passw|pwd|credential|auth|bearer|cookie|session)`)
|
|
|
|
func secretName(name string) bool { return secretNameRe.MatchString(name) }
|
|
|
|
// isReference: the value names where the secret comes from (${env:X},
|
|
// ${input:x}, $X, %X%) rather than holding it.
|
|
func isReference(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
return strings.Contains(v, "${") || strings.HasPrefix(v, "$") ||
|
|
(len(v) > 2 && strings.HasPrefix(v, "%") && strings.HasSuffix(v, "%"))
|
|
}
|
|
|
|
// secretShaped answers whether one name/value pair holds a secret in plain
|
|
// text: the value has a known credential's shape, or the name says secret and
|
|
// the value is a literal long enough to be one. The value is not returned or
|
|
// kept; the caller gets a bool.
|
|
func secretShaped(name, value string) bool {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" || isReference(v) {
|
|
return false
|
|
}
|
|
if secretPrefixes.MatchString(v) {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(v), "bearer ") || strings.HasPrefix(strings.ToLower(v), "basic ") {
|
|
rest := strings.TrimSpace(v[strings.IndexByte(v, ' ')+1:])
|
|
return len(rest) >= 8 && !isReference(rest)
|
|
}
|
|
if !secretName(name) || len(v) < 8 {
|
|
return false
|
|
}
|
|
// A path or a plain URL under a secret-sounding name (a key FILE, an
|
|
// auth SERVER) is where a secret lives, not the secret.
|
|
if filepath.IsAbs(v) || strings.HasPrefix(v, "~") || strings.HasPrefix(v, "./") {
|
|
return false
|
|
}
|
|
if u, err := url.Parse(v); err == nil && u.Scheme != "" && u.Host != "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isPureReference: the whole value is one ${...} reference (${VAR},
|
|
// ${env:VAR}, ${input:id}), which the client expands and which names where
|
|
// the secret comes from. Only that form is kept on a redacted command line:
|
|
// a bare $NAME is passed through literally by clients that start a server
|
|
// without a shell, so it may well be the secret itself.
|
|
func isPureReference(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
return strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}") && strings.Count(v, "${") == 1 &&
|
|
strings.IndexByte(v, '}') == len(v)-1
|
|
}
|
|
|
|
// redactSecretArgs replaces every argument that holds a secret with
|
|
// mcpRedacted: one with a credential's shape, the value after or inside a
|
|
// flag whose name says secret, a NAME=value whose name does, a header
|
|
// argument (Authorization: ..., X-Api-Key: ...), and the userinfo, path and
|
|
// query of a URL. found reports whether a value matched a secret's shape;
|
|
// redaction itself is wider than that, because a value that does not look
|
|
// like a secret can still be one.
|
|
func redactSecretArgs(args []string) (out []string, found bool) {
|
|
out = make([]string, len(args))
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if strings.HasPrefix(a, "-") {
|
|
flag, val, hasVal := strings.Cut(strings.TrimLeft(a, "-"), "=")
|
|
if hasVal {
|
|
v, f := redactValue(flag, val)
|
|
out[i], found = a[:len(a)-len(val)]+v, found || f
|
|
continue
|
|
}
|
|
out[i] = a
|
|
if secretName(flag) && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
v, f := redactValue(flag, args[i+1])
|
|
out[i+1], found = v, found || f
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
v, f := redactValue("", a)
|
|
out[i], found = v, found || f
|
|
}
|
|
return out, found
|
|
}
|
|
|
|
// redactValue is one value with whatever secret it carries replaced. name is
|
|
// the flag or variable it was given under, "" for a positional argument.
|
|
func redactValue(name, v string) (string, bool) {
|
|
if v == "" || isPureReference(v) {
|
|
return v, false
|
|
}
|
|
if secretPrefixes.MatchString(v) {
|
|
return mcpRedacted, true
|
|
}
|
|
if secretName(name) {
|
|
return mcpRedacted, secretShaped(name, v)
|
|
}
|
|
if low := strings.ToLower(v); strings.HasPrefix(low, "bearer ") || strings.HasPrefix(low, "basic ") {
|
|
return mcpRedacted, secretShaped(name, v)
|
|
}
|
|
if i := strings.Index(v, "://"); i > 0 && !strings.ContainsAny(v[:i], " =:") {
|
|
return redactURL(v)
|
|
}
|
|
if k, val, ok := strings.Cut(v, "="); ok && k != "" && !strings.ContainsAny(k, " /\\:") {
|
|
r, f := redactValue(k, val)
|
|
return k + "=" + r, f
|
|
}
|
|
if k, val, ok := strings.Cut(v, ":"); ok && k != "" && !strings.ContainsAny(k, " /\\") && mcpAuthHeader(k) {
|
|
val = strings.TrimSpace(val)
|
|
rest := val
|
|
if low := strings.ToLower(rest); strings.HasPrefix(low, "bearer ") || strings.HasPrefix(low, "basic ") {
|
|
rest = strings.TrimSpace(rest[strings.IndexByte(rest, ' ')+1:])
|
|
}
|
|
if rest == "" || isPureReference(rest) {
|
|
return v, false
|
|
}
|
|
return k + ": " + mcpRedacted, secretShaped(k, val)
|
|
}
|
|
return v, false
|
|
}
|
|
|
|
// redactURL keeps a URL's scheme and host and replaces the rest: userinfo
|
|
// carries credentials, and a path or query can carry a token as easily as a
|
|
// named parameter does. found: a password, a secret-shaped user, or a
|
|
// secret-named query parameter with a value.
|
|
func redactURL(raw string) (string, bool) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return mcpRedacted, secretPrefixes.MatchString(raw)
|
|
}
|
|
_, found, _ := mcpURLFacts(raw)
|
|
out := u.Scheme + "://"
|
|
if u.User != nil {
|
|
out += mcpRedacted + "@"
|
|
if secretPrefixes.MatchString(u.User.Username()) {
|
|
found = true
|
|
}
|
|
}
|
|
out += truncate(u.Host, mcpHostMax)
|
|
if strings.Trim(u.EscapedPath(), "/") != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
|
out += "/" + mcpRedacted
|
|
}
|
|
return out, found
|
|
}
|
|
|
|
// ── packages: where the server's code comes from, and whether it is pinned ──
|
|
|
|
var (
|
|
exactSemver = regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
|
exactPEP440 = regexp.MustCompile(`^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?$`)
|
|
)
|
|
|
|
// npm flags that take a value, so the value is not mistaken for the package.
|
|
var npxValueFlags = map[string]bool{"-p": true, "--package": true, "--registry": true,
|
|
"--cache": true, "--prefix": true, "-c": true, "--call": true, "--userconfig": true}
|
|
|
|
// uvx flags that take a value; --from names the package itself.
|
|
var uvxValueFlags = map[string]bool{"--from": true, "--with": true, "-w": true, "--python": true,
|
|
"-p": true, "--index": true, "--index-url": true, "--extra-index-url": true,
|
|
"--with-requirements": true, "--constraint": true, "-c": true}
|
|
|
|
// docker run flags that take a value (the common ones a server entry uses).
|
|
var dockerValueFlags = map[string]bool{"-e": true, "--env": true, "--env-file": true, "-v": true,
|
|
"--volume": true, "--mount": true, "--name": true, "--network": true, "--net": true,
|
|
"-p": true, "--publish": true, "-w": true, "--workdir": true, "-u": true, "--user": true,
|
|
"--entrypoint": true, "-l": true, "--label": true, "--platform": true, "--pull": true,
|
|
"--memory": true, "-m": true, "--cpus": true, "--add-host": true, "--cap-add": true,
|
|
"--cap-drop": true, "--security-opt": true, "--hostname": true, "-h": true, "--init-path": true}
|
|
|
|
// mcpPackage names where a stdio server's code comes from.
|
|
func mcpPackage(command string, args []string) (source, name, pinned string) {
|
|
base := runnerBase(command)
|
|
switch {
|
|
case base == "npx" || base == "bunx" || base == "pnpx" ||
|
|
((base == "pnpm" || base == "yarn") && len(args) > 0 && args[0] == "dlx") ||
|
|
(base == "npm" && len(args) > 0 && args[0] == "exec"):
|
|
if base == "pnpm" || base == "yarn" || base == "npm" {
|
|
args = args[1:]
|
|
}
|
|
spec := firstPositional(args, npxValueFlags, map[string]bool{"-p": true, "--package": true})
|
|
n, ver := splitNPMSpec(spec)
|
|
return "npx", n, pinnedWord(ver, exactSemver, spec)
|
|
case base == "uvx" || (base == "uv" && len(args) > 1 && args[0] == "tool" && args[1] == "run"):
|
|
if base == "uv" {
|
|
args = args[2:]
|
|
}
|
|
spec := firstPositional(args, uvxValueFlags, map[string]bool{"--from": true})
|
|
n, ver := splitPySpec(spec)
|
|
return "uvx", n, pinnedWord(ver, exactPEP440, spec)
|
|
case base == "pipx" && len(args) > 0 && args[0] == "run":
|
|
spec := firstPositional(args[1:], map[string]bool{"--spec": true, "--python": true}, map[string]bool{"--spec": true})
|
|
n, ver := splitPySpec(spec)
|
|
return "pip", n, pinnedWord(ver, exactPEP440, spec)
|
|
case base == "docker" || base == "podman":
|
|
i := 0
|
|
for i < len(args) && args[i] != "run" {
|
|
i++
|
|
}
|
|
if i == len(args) {
|
|
return "docker", "", mcpUnknown
|
|
}
|
|
image := firstPositional(args[i+1:], dockerValueFlags, nil)
|
|
n, digest := splitImage(image)
|
|
if image == "" {
|
|
return "docker", "", mcpUnknown
|
|
}
|
|
return "docker", n, yesNo(digest)
|
|
case interpreters[base]:
|
|
// node server.js, python -m pkg: the code is a script or module on
|
|
// disk, not a package the command line names.
|
|
return mcpUnknown, "", mcpUnknown
|
|
}
|
|
return "binary", "", mcpUnknown
|
|
}
|
|
|
|
// runnerBase is binBase with the Windows script suffixes dropped too: npm
|
|
// installs npx as npx.cmd there, and a config names it either way.
|
|
func runnerBase(s string) string {
|
|
b := binBase(s)
|
|
for _, suf := range []string{".cmd", ".bat", ".ps1"} {
|
|
b = strings.TrimSuffix(b, suf)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// firstPositional is the first argument that is not a flag or a flag's value.
|
|
// A flag in pick names the package itself, and its value is returned.
|
|
func firstPositional(args []string, valueFlags, pick map[string]bool) string {
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if f, v, ok := strings.Cut(a, "="); ok && strings.HasPrefix(a, "-") {
|
|
if pick[f] {
|
|
return v
|
|
}
|
|
continue
|
|
}
|
|
if pick[a] && i+1 < len(args) {
|
|
return args[i+1]
|
|
}
|
|
if valueFlags[a] {
|
|
i++
|
|
continue
|
|
}
|
|
if strings.HasPrefix(a, "-") {
|
|
continue
|
|
}
|
|
return a
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// splitNPMSpec splits "@scope/name@1.2.3" or "name@^1" into name and version.
|
|
func splitNPMSpec(spec string) (name, version string) {
|
|
if spec == "" {
|
|
return "", ""
|
|
}
|
|
if strings.Contains(spec, "://") {
|
|
// git+https://user:token@host/..., a tarball URL: not a registry
|
|
// name, and its userinfo can carry a credential.
|
|
n, _ := redactURL(spec)
|
|
return n, ""
|
|
}
|
|
at := strings.LastIndexByte(spec, '@')
|
|
if at <= 0 {
|
|
return spec, ""
|
|
}
|
|
return spec[:at], spec[at+1:]
|
|
}
|
|
|
|
// splitPySpec splits "name==1.2", "name@1.2" or "name>=1" into name and the
|
|
// exact version when the spec pins one ("" otherwise, with a range kept as a
|
|
// non-matching version so it reads as not pinned).
|
|
func splitPySpec(spec string) (name, version string) {
|
|
if strings.Contains(spec, "://") {
|
|
n, _ := redactURL(spec)
|
|
return n, ""
|
|
}
|
|
if i := strings.Index(spec, "=="); i > 0 {
|
|
return spec[:i], spec[i+2:]
|
|
}
|
|
if i := strings.IndexByte(spec, '@'); i > 0 {
|
|
return spec[:i], spec[i+1:]
|
|
}
|
|
if i := strings.IndexAny(spec, "<>~!="); i > 0 {
|
|
return spec[:i], spec[i:]
|
|
}
|
|
return spec, ""
|
|
}
|
|
|
|
// splitImage splits an image reference into its name and whether it is
|
|
// pinned by digest. A tag can be moved to other content; only a digest
|
|
// cannot, so only a digest counts as pinned.
|
|
func splitImage(ref string) (name string, digest bool) {
|
|
if i := strings.Index(ref, "@sha256:"); i > 0 {
|
|
return ref[:i], true
|
|
}
|
|
slash := strings.LastIndexByte(ref, '/')
|
|
if c := strings.LastIndexByte(ref, ':'); c > slash {
|
|
return ref[:c], false
|
|
}
|
|
return ref, false
|
|
}
|
|
|
|
// pinnedWord: yes for an exact version, no for a range, a tag or none, and
|
|
// unknown when the spec is a path, a URL or a git reference.
|
|
func pinnedWord(version string, exact *regexp.Regexp, spec string) string {
|
|
if spec == "" || strings.ContainsAny(spec, `/\`) && !strings.HasPrefix(spec, "@") ||
|
|
strings.Contains(spec, ":") {
|
|
return mcpUnknown
|
|
}
|
|
return yesNo(exact.MatchString(version))
|
|
}
|
|
|
|
// mcpMatchKey is what finds this server in a process list: the package name
|
|
// for a packaged server, the script for an interpreter, the program's
|
|
// basename otherwise.
|
|
func mcpMatchKey(command string, args []string, pkg string) (sub, base string) {
|
|
if pkg != "" {
|
|
return strings.ToLower(pkg), ""
|
|
}
|
|
b := runnerBase(command)
|
|
if interpreters[b] {
|
|
for _, a := range args {
|
|
if !strings.HasPrefix(a, "-") {
|
|
return strings.ToLower(a), ""
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
return "", b
|
|
}
|
|
|
|
// ── correlation with the process list ───────────────────────────────────────
|
|
|
|
// mcpProc is one process as the platform enumerator saw it. Argv is empty
|
|
// when the platform did not read this process's command line (Windows reads
|
|
// it only for candidates); such a process still carries its parent link.
|
|
type mcpProc struct {
|
|
PID int
|
|
PPID int
|
|
Argv []string
|
|
}
|
|
|
|
// mcpWantedBases is the program basenames whose command lines the Windows
|
|
// enumerator must read: every interpreter and package runner, plus every
|
|
// program a declared server names.
|
|
func mcpWantedBases(declared []mcpServerObs) map[string]bool {
|
|
want := map[string]bool{"docker": true, "podman": true, "pipx": true, "pnpm": true, "yarn": true,
|
|
"npm": true, "bunx": true, "pnpx": true}
|
|
for b := range interpreters {
|
|
want[b] = true
|
|
}
|
|
for _, d := range declared {
|
|
if d.matchBase != "" {
|
|
want[d.matchBase] = true
|
|
}
|
|
}
|
|
return want
|
|
}
|
|
|
|
// correlateMCP sets running_state on every declared server and adds the
|
|
// servers that are running with no declaration. procsOK false means the
|
|
// process list could not be read, and then nothing is guessed: every local
|
|
// server reads unknown and no undeclared server is reported.
|
|
func correlateMCP(declared []mcpServerObs, procs []mcpProc, procsOK bool) []mcpServerObs {
|
|
byPID := map[int]mcpProc{}
|
|
for _, p := range procs {
|
|
byPID[p.PID] = p
|
|
}
|
|
// Direct matches: a process whose command line names a declared server.
|
|
owner := map[int][]int{} // pid -> indexes into declared
|
|
for pid, p := range byPID {
|
|
if len(p.Argv) == 0 {
|
|
continue
|
|
}
|
|
low := strings.ToLower(strings.Join(p.Argv, " "))
|
|
for i, d := range declared {
|
|
if d.Transport != "stdio" {
|
|
continue
|
|
}
|
|
if (d.matchSub != "" && strings.Contains(low, d.matchSub)) ||
|
|
(d.matchBase != "" && binBase(p.Argv[0]) == d.matchBase) {
|
|
owner[pid] = append(owner[pid], i)
|
|
}
|
|
}
|
|
}
|
|
// A package runner starts the server as a child (npx -> node .../bin/x),
|
|
// and the child is the same server: it inherits its parent's match.
|
|
inherit := func(pid int) []int {
|
|
seen := map[int]bool{}
|
|
for hop := 0; hop < maxHelperHops; hop++ {
|
|
p, ok := byPID[pid]
|
|
if !ok || seen[pid] {
|
|
return nil
|
|
}
|
|
seen[pid] = true
|
|
if m := owner[p.PPID]; len(m) > 0 {
|
|
return m
|
|
}
|
|
pid = p.PPID
|
|
}
|
|
return nil
|
|
}
|
|
matched := map[int][]int{}
|
|
for pid := range byPID {
|
|
if m := owner[pid]; len(m) > 0 {
|
|
matched[pid] = m
|
|
} else if m := inherit(pid); len(m) > 0 {
|
|
matched[pid] = m
|
|
}
|
|
}
|
|
pids := make([][]int, len(declared))
|
|
for pid, idx := range matched {
|
|
for _, i := range idx {
|
|
pids[i] = append(pids[i], pid)
|
|
}
|
|
}
|
|
out := make([]mcpServerObs, 0, len(declared))
|
|
for i, d := range declared {
|
|
switch {
|
|
case d.Transport != "stdio" && (d.Locality == "remote" || d.Locality == "lan"):
|
|
d.RunningState = mcpStateNotLocal
|
|
case d.Transport != "stdio" || !procsOK:
|
|
d.RunningState = mcpStateUnknown
|
|
case len(pids[i]) > 0:
|
|
d.RunningState = mcpStateRunning
|
|
sort.Ints(pids[i])
|
|
if len(pids[i]) > mcpMaxPIDs {
|
|
pids[i] = pids[i][:mcpMaxPIDs]
|
|
}
|
|
d.PIDs = pids[i]
|
|
default:
|
|
d.RunningState = mcpStateIdle
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
if !procsOK {
|
|
return out
|
|
}
|
|
// Undeclared: a process that looks like a tool server, matched by no
|
|
// declaration, and not itself the child of one already reported.
|
|
shadow := map[int]bool{}
|
|
for pid, p := range byPID {
|
|
if len(matched[pid]) == 0 && looksLikeMCPServer(p.Argv) {
|
|
shadow[pid] = true
|
|
}
|
|
}
|
|
var shadowPIDs []int
|
|
for pid := range shadow {
|
|
if !shadowAncestor(byPID, shadow, pid) {
|
|
shadowPIDs = append(shadowPIDs, pid)
|
|
}
|
|
}
|
|
sort.Ints(shadowPIDs)
|
|
for _, pid := range shadowPIDs {
|
|
if len(out) >= mcpMaxServers {
|
|
break
|
|
}
|
|
p := byPID[pid]
|
|
args, secret := redactSecretArgs(p.Argv[1:])
|
|
src, name, pinned := mcpPackage(p.Argv[0], p.Argv[1:])
|
|
name = truncate(name, mcpNameMax)
|
|
out = append(out, mcpServerObs{Name: name, Transport: mcpUnknown, Locality: mcpLocalProcess,
|
|
Command: truncate(strings.Join(append([]string{p.Argv[0]}, args...), " "), mcpCommandMax),
|
|
PackageSource: src, PackageName: name, PackagePinned: pinned, SecretInPlaintext: secret,
|
|
AuthConfigured: mcpNotApplicable, BindClass: mcpUnknown,
|
|
RunningState: mcpStateShadow, PIDs: []int{pid}})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func shadowAncestor(byPID map[int]mcpProc, shadow map[int]bool, pid int) bool {
|
|
seen := map[int]bool{pid: true}
|
|
for hop := 0; hop < maxHelperHops; hop++ {
|
|
p, ok := byPID[pid]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if shadow[p.PPID] {
|
|
return true
|
|
}
|
|
if seen[p.PPID] {
|
|
return false
|
|
}
|
|
seen[p.PPID] = true
|
|
pid = p.PPID
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mcpNPMScope is the npm scope the protocol's reference servers are published
|
|
// under; a command line naming it is running one of them.
|
|
const mcpNPMScope = "@modelcontextprotocol/"
|
|
|
|
// looksLikeMCPServer: a process whose command line names a tool server — a
|
|
// program or package whose name carries "mcp" as a word (github-mcp-server,
|
|
// mcp-server-git, fastmcp, mcp-remote) or the reference servers' npm scope.
|
|
// Only for a program that runs servers (an interpreter or package runner) or
|
|
// one whose own name carries the word: an agent started with --mcp-config is
|
|
// a client, and flags are never read. Soft, like every process signal here.
|
|
func looksLikeMCPServer(argv []string) bool {
|
|
if len(argv) == 0 {
|
|
return false
|
|
}
|
|
if _, isAgent := matchAgentSignature(argv); isAgent {
|
|
return false
|
|
}
|
|
base := runnerBase(argv[0])
|
|
if mcpWord(base) {
|
|
return true
|
|
}
|
|
if !interpreters[base] && base != "docker" && base != "podman" && base != "npm" && base != "pipx" {
|
|
return false
|
|
}
|
|
for _, a := range argv[1:] {
|
|
if strings.HasPrefix(a, "-") {
|
|
continue
|
|
}
|
|
low := strings.ToLower(a)
|
|
if strings.Contains(low, mcpNPMScope) {
|
|
return true
|
|
}
|
|
for _, part := range strings.FieldsFunc(low, func(r rune) bool {
|
|
return r == '/' || r == '\\' || r == '@' || r == ':' || r == '='
|
|
}) {
|
|
if mcpWord(part) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mcpWord: "mcp" as a whole word inside one name, split on - _ and .
|
|
func mcpWord(s string) bool {
|
|
for _, w := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) {
|
|
if w == "mcp" || w == "fastmcp" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── JSON with comments, and the subset of TOML Codex writes ─────────────────
|
|
|
|
// stripJSONC removes // and /* */ comments and trailing commas outside
|
|
// strings, so Zed's and VS Code's files parse as JSON. Plain JSON passes
|
|
// through unchanged.
|
|
func stripJSONC(b []byte) []byte {
|
|
out := make([]byte, 0, len(b))
|
|
inStr, esc := false, false
|
|
for i := 0; i < len(b); i++ {
|
|
c := b[i]
|
|
if inStr {
|
|
out = append(out, c)
|
|
switch {
|
|
case esc:
|
|
esc = false
|
|
case c == '\\':
|
|
esc = true
|
|
case c == '"':
|
|
inStr = false
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case c == '"':
|
|
inStr = true
|
|
out = append(out, c)
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
|
for i < len(b) && b[i] != '\n' {
|
|
i++
|
|
}
|
|
if i < len(b) {
|
|
out = append(out, '\n')
|
|
}
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
|
i += 2
|
|
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
|
|
i++
|
|
}
|
|
i++
|
|
case c == '}' || c == ']':
|
|
// Drop a comma that only whitespace separates from this close.
|
|
j := len(out) - 1
|
|
for j >= 0 && (out[j] == ' ' || out[j] == '\t' || out[j] == '\n' || out[j] == '\r') {
|
|
j--
|
|
}
|
|
if j >= 0 && out[j] == ',' {
|
|
out = append(out[:j], out[j+1:]...)
|
|
}
|
|
out = append(out, c)
|
|
default:
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseTOMLSubset reads the part of TOML a Codex config uses: [table] and
|
|
// [a."b c"] headers, key = value with dotted and quoted keys, basic and
|
|
// literal strings (and their multi-line forms), arrays (across lines),
|
|
// inline tables, and bare scalars kept as their text (true/false become
|
|
// booleans). [[array of tables]] sections are skipped: Codex keeps no
|
|
// server there. A line it cannot read is an error for the whole file, which
|
|
// counts as unparsed rather than silently half-read.
|
|
func parseTOMLSubset(src string) (map[string]any, error) {
|
|
root := map[string]any{}
|
|
cur := root
|
|
skipping := false
|
|
p := &tomlParser{s: src}
|
|
for {
|
|
p.skipSpaceAndComments(true)
|
|
if p.eof() {
|
|
return root, nil
|
|
}
|
|
if p.peek() == '[' {
|
|
if strings.HasPrefix(p.s[p.i:], "[[") {
|
|
skipping = true
|
|
p.skipLine()
|
|
continue
|
|
}
|
|
p.i++
|
|
keys, err := p.keyPath(']')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.i++ // ]
|
|
skipping = false
|
|
cur = root
|
|
for _, k := range keys {
|
|
next, ok := cur[k].(map[string]any)
|
|
if !ok {
|
|
next = map[string]any{}
|
|
cur[k] = next
|
|
}
|
|
cur = next
|
|
}
|
|
p.skipLine()
|
|
continue
|
|
}
|
|
keys, err := p.keyPath('=')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.i++ // =
|
|
v, err := p.value()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !skipping {
|
|
setDotted(cur, keys, v)
|
|
}
|
|
p.skipLine()
|
|
}
|
|
}
|
|
|
|
func setDotted(m map[string]any, keys []string, v any) {
|
|
for _, k := range keys[:len(keys)-1] {
|
|
next, ok := m[k].(map[string]any)
|
|
if !ok {
|
|
next = map[string]any{}
|
|
m[k] = next
|
|
}
|
|
m = next
|
|
}
|
|
m[keys[len(keys)-1]] = v
|
|
}
|
|
|
|
type tomlParser struct {
|
|
s string
|
|
i int
|
|
depth int // arrays and inline tables open around the current value
|
|
}
|
|
|
|
var errTOML = errors.New("toml: cannot read this line")
|
|
|
|
func (p *tomlParser) eof() bool { return p.i >= len(p.s) }
|
|
func (p *tomlParser) peek() byte { return p.s[p.i] }
|
|
|
|
func (p *tomlParser) skipSpaceAndComments(newlines bool) {
|
|
for !p.eof() {
|
|
c := p.peek()
|
|
switch {
|
|
case c == ' ' || c == '\t' || c == '\r' || (newlines && c == '\n'):
|
|
p.i++
|
|
case c == '#':
|
|
for !p.eof() && p.peek() != '\n' {
|
|
p.i++
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// skipLine moves past the rest of the line (trailing space and a comment).
|
|
func (p *tomlParser) skipLine() {
|
|
for !p.eof() && p.peek() != '\n' {
|
|
p.i++
|
|
}
|
|
}
|
|
|
|
// keyPath reads a dotted key up to (not past) the stop byte.
|
|
func (p *tomlParser) keyPath(stop byte) ([]string, error) {
|
|
var keys []string
|
|
for {
|
|
p.skipSpaceAndComments(false)
|
|
if p.eof() {
|
|
return nil, errTOML
|
|
}
|
|
var k string
|
|
switch p.peek() {
|
|
case '"', '\'':
|
|
s, err := p.str()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
k = s
|
|
default:
|
|
st := p.i
|
|
for !p.eof() && (isBareKeyByte(p.peek())) {
|
|
p.i++
|
|
}
|
|
if p.i == st {
|
|
return nil, errTOML
|
|
}
|
|
k = p.s[st:p.i]
|
|
}
|
|
keys = append(keys, k)
|
|
p.skipSpaceAndComments(false)
|
|
if p.eof() {
|
|
return nil, errTOML
|
|
}
|
|
switch p.peek() {
|
|
case '.':
|
|
p.i++
|
|
case stop:
|
|
return keys, nil
|
|
default:
|
|
return nil, errTOML
|
|
}
|
|
}
|
|
}
|
|
|
|
func isBareKeyByte(c byte) bool {
|
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-'
|
|
}
|
|
|
|
func (p *tomlParser) value() (any, error) {
|
|
p.skipSpaceAndComments(false)
|
|
if p.eof() {
|
|
return nil, errTOML
|
|
}
|
|
switch p.peek() {
|
|
case '"', '\'':
|
|
return p.str()
|
|
case '[', '{':
|
|
// Each level is a stack frame; a crafted file a million deep must
|
|
// not walk the stack that far.
|
|
if p.depth >= mcpMaxNesting {
|
|
return nil, errTOML
|
|
}
|
|
p.depth++
|
|
defer func() { p.depth-- }()
|
|
}
|
|
switch p.peek() {
|
|
case '[':
|
|
p.i++
|
|
var out []any
|
|
for {
|
|
p.skipSpaceAndComments(true)
|
|
if p.eof() {
|
|
return nil, errTOML
|
|
}
|
|
if p.peek() == ']' {
|
|
p.i++
|
|
return out, nil
|
|
}
|
|
v, err := p.value()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
p.skipSpaceAndComments(true)
|
|
if !p.eof() && p.peek() == ',' {
|
|
p.i++
|
|
}
|
|
}
|
|
case '{':
|
|
p.i++
|
|
out := map[string]any{}
|
|
for {
|
|
p.skipSpaceAndComments(false)
|
|
if p.eof() {
|
|
return nil, errTOML
|
|
}
|
|
if p.peek() == '}' {
|
|
p.i++
|
|
return out, nil
|
|
}
|
|
keys, err := p.keyPath('=')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.i++
|
|
v, err := p.value()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
setDotted(out, keys, v)
|
|
p.skipSpaceAndComments(false)
|
|
if !p.eof() && p.peek() == ',' {
|
|
p.i++
|
|
}
|
|
}
|
|
}
|
|
st := p.i
|
|
for !p.eof() && !strings.ContainsRune(",]}\n#", rune(p.peek())) {
|
|
p.i++
|
|
}
|
|
raw := strings.TrimSpace(p.s[st:p.i])
|
|
switch raw {
|
|
case "":
|
|
return nil, errTOML
|
|
case "true":
|
|
return true, nil
|
|
case "false":
|
|
return false, nil
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// str reads a basic ("...", with escapes) or literal ('...') string, either
|
|
// in its one-line or its triple-quoted multi-line form.
|
|
func (p *tomlParser) str() (string, error) {
|
|
q := p.peek()
|
|
triple := strings.Repeat(string(q), 3)
|
|
if strings.HasPrefix(p.s[p.i:], triple) {
|
|
p.i += 3
|
|
if strings.HasPrefix(p.s[p.i:], "\n") {
|
|
p.i++
|
|
} else if strings.HasPrefix(p.s[p.i:], "\r\n") {
|
|
p.i += 2
|
|
}
|
|
end := strings.Index(p.s[p.i:], triple)
|
|
if end < 0 {
|
|
return "", errTOML
|
|
}
|
|
body := p.s[p.i : p.i+end]
|
|
p.i += end + 3
|
|
if q == '"' {
|
|
return tomlUnescape(body), nil
|
|
}
|
|
return body, nil
|
|
}
|
|
p.i++
|
|
var b strings.Builder
|
|
for !p.eof() {
|
|
c := p.peek()
|
|
switch {
|
|
case c == '\n':
|
|
return "", errTOML
|
|
case c == q:
|
|
p.i++
|
|
if q == '"' {
|
|
return tomlUnescape(b.String()), nil
|
|
}
|
|
return b.String(), nil
|
|
case c == '\\' && q == '"' && p.i+1 < len(p.s):
|
|
b.WriteByte(c)
|
|
b.WriteByte(p.s[p.i+1])
|
|
p.i += 2
|
|
default:
|
|
b.WriteByte(c)
|
|
p.i++
|
|
}
|
|
}
|
|
return "", errTOML
|
|
}
|
|
|
|
// tomlUnescape decodes the escapes of a TOML basic string: \\ \" \b \t
|
|
// \n \f \r and \uXXXX / \UXXXXXXXX. An escape it does not know is kept
|
|
// as written.
|
|
func tomlUnescape(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c != '\\' || i+1 >= len(s) {
|
|
b.WriteByte(c)
|
|
continue
|
|
}
|
|
i++
|
|
switch s[i] {
|
|
case '\\', '"':
|
|
b.WriteByte(s[i])
|
|
case 'b':
|
|
b.WriteByte('\b')
|
|
case 't':
|
|
b.WriteByte('\t')
|
|
case 'n':
|
|
b.WriteByte('\n')
|
|
case 'f':
|
|
b.WriteByte('\f')
|
|
case 'r':
|
|
b.WriteByte('\r')
|
|
case 'u', 'U':
|
|
n := 4
|
|
if s[i] == 'U' {
|
|
n = 8
|
|
}
|
|
if i+n < len(s) {
|
|
if r, err := strconv.ParseUint(s[i+1:i+1+n], 16, 32); err == nil {
|
|
b.WriteRune(rune(r))
|
|
i += n
|
|
continue
|
|
}
|
|
}
|
|
b.WriteByte('\\')
|
|
b.WriteByte(s[i])
|
|
default:
|
|
b.WriteByte('\\')
|
|
b.WriteByte(s[i])
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|