Skip to main content
Glama
sblattj

mcp-working-set

by sblattj

mcp-working-set

An MCP proxy that sits in front of any MCP server and lists only the tools you actually use, plus two meta-tools (search_tools, call_tool) that reach everything else in one hop.

The problem

A client that connects to an MCP server loads every tool's name, description and input schema into the model's context, every session. A gateway with a few hundred tools costs on the order of 100k tokens before the first message, and most of those tools are never called.

S-T: every tool in the list is text the model has to read at the start of each conversation, whether it uses the tool or not. 300 tools can be a whole book's chapter of text, paid for every time.

Related MCP server: mcpproxy-go

The answer

  • Working set. tools/list returns search_tools, call_tool, and the tools in working-set.json (promoted and pinned names) that the upstream currently lists. Nothing else.

  • Usage promotion. mws promote reads client transcripts and the proxy's own call log and promotes the tools that succeed across several sessions. Tools that stop being used fall out after a grace period.

  • One-hop search. search_tools {query} ranks the full upstream catalog (BM25 over names, parameter names and descriptions, with a small synonym table) and returns the best matches with their input schemas. call_tool {name, arguments} runs any of them.

  • Everything stays callable. A direct tools/call for an unlisted tool is forwarded upstream too, so a client holding an older listing keeps working after a demotion.

S-T: instead of handing the model the whole phone book, the proxy hands it the 20 numbers you dial most, plus a way to look up the rest.

Quick start

Requires Bun 1.3 or newer.

git clone https://github.com/sblattj/mcp-working-set.git && cd mcp-working-set
bun install
bun run src/cli.ts init      # writes ~/.config/mcp-working-set/config.json
# edit "upstream" in that file, then:
bun run src/cli.ts serve     # listens on http://127.0.0.1:4484/mcp

bun link puts an mws command on your PATH (mws serve, mws search ...). The package is not on npm yet, so bunx mcp-working-set does not work until it is.

Point your client at the proxy instead of the upstream server.

Claude Code

claude mcp add --transport http toolbox http://127.0.0.1:4484/mcp

Use the same name as serverName in the config (default toolbox): the promoter finds calls in Claude Code transcripts by the mcp__<serverName>__<tool> prefix.

Codex (~/.codex/config.toml, in Codex versions that support Streamable HTTP servers)

[mcp_servers.toolbox]
url = "http://127.0.0.1:4484/mcp"

Any other client: a Streamable HTTP MCP server at http://127.0.0.1:4484/mcp. Clients on the stateless 2026-07-28 protocol and older (2025) clients are both served.

Then keep the working set fresh, for example from cron or a launchd/systemd timer:

mws promote            # rewrites working-set.json; the running proxy picks it up on its next tools/list
mws promote --dry-run  # show the decision table without writing
mws report             # what is listed, its size, call and search stats (--json for machines)
mws search "share a file"   # run search_tools against the live catalog and print the reply

Upstreams

The proxy talks to the upstream through the official MCP TypeScript SDK client (@modelcontextprotocol/client) with protocol negotiation on auto:

upstream

how

Streamable HTTP, 2026-07-28 (stateless)

probed with server/discover; every request then carries MCP-Protocol-Version, Mcp-Method, Mcp-Name and the per-request _meta envelope (the SDK adds them)

Streamable HTTP, 2025 (session-based)

falls back to the initialize handshake and an Mcp-Session-Id session; an expired session is re-opened on the next tools/list (a tools/call that lands on the expired session fails once; calls are not retried because they may have side effects)

stdio command

spawned as a child process, same negotiation

On the stateless protocol the proxy also names the calling client in the forwarded request (clientInfo.name = mcp-working-set/<client>). On the session-based protocol and stdio the upstream only sees the proxy's own name.

Configuration

One JSON file. Path: --config <path>, else $MWS_CONFIG, else ~/.config/mcp-working-set/config.json. A missing file means all defaults. Every field is optional.

{
  "upstream": { "url": "http://127.0.0.1:4483/mcp" },
  "listen": { "host": "127.0.0.1", "port": 4484 },
  "stateDir": "~/.local/state/mcp-working-set",
  "serverName": "toolbox",
  "backendHints": { "mail": "mailbox and filters", "drive": "files and docs" },
  "pins": [],
  "pinFiles": ["~/.claude/skills/*/SKILL.md"],
  "promote": { "minSessions": 2, "minCalls": 1, "windowDays": 30, "keepDays": 60, "pinDecayDays": 90 },
  "transcripts": [{ "type": "claude-code", "root": "~/.claude/projects" }]
}

field

meaning

upstream

{ "url": "...", "headers": {...} } for HTTP, or { "command": "npx", "args": [...], "env": {...}, "cwd": "..." } for stdio

listen

where the proxy listens. Loopback only: the listener validates Host and Origin against localhost and refuses a non-loopback host

stateDir

holds working-set.json, calls.jsonl (call and search log) and catalog.json (last-good upstream catalog)

serverName

the name your client registered the proxy under; used in the generated server instructions and to find mcp__<serverName>__<tool> calls in transcripts

backendHints

short labels per backend prefix (mail in mail_send_message), shown in the server instructions and the search_tools description

pins

tool names that are always listed and never decay

pinFiles

globs of Markdown files whose front matter may carry working-set-pins: [tool_a, tool_b] (for example a skill that needs certain tools)

promote

promotion thresholds, see below

transcripts

where to read past tool calls from; the claude-code adapter reads <root>/**/*.jsonl

timeouts

{ "listMs": 5000, "callMs": 120000, "catalogTtlMs": 30000 }

compactDescriptions

true keeps only the first paragraph of each listed tool's description (default false)

searchAliases

extra query synonyms, e.g. { "ticket": ["issue"] }, merged over the built-in table

How promotion works

mws promote collects every tool call it can see: calls in client transcripts, plus the proxy's own calls.jsonl. Then, per tool:

  • Sessions, not calls. A tool is promoted when it had successful calls in at least minSessions distinct sessions (and at least minCalls successful calls) within the last windowDays. One long session that hammers a tool does not promote it; a tool you reach for on different days does.

  • Only successes count. A call that returned an error (or isError) is ignored, so a tool the model keeps fumbling does not earn a slot.

  • Hysteresis. Once promoted, a tool stays listed while it had at least one successful session in the last keepDays (longer than windowDays). It is demoted only after that. This stops tools from flapping in and out of the list.

  • Pins. pins in the config are listed unconditionally. Front-matter pins from pinFiles are listed while they are used at least once every pinDecayDays; an unused one goes stale and drops off, so an old skill does not hold a slot forever.

  • Reality check. Names the upstream no longer lists are dropped from the result.

The proxy re-reads working-set.json whenever its modification time changes, so the next tools/list from any client reflects a promotion without a restart.

S-T: a tool earns its place by being useful on different days, keeps it for a while after you stop using it, and loses it if you stop for long enough.

search_tools {query}:

  • Exact name, with or without the backend prefix (mail_list_labels or list_labels): that tool alone, with its full upstream schema, uncapped.

  • A backend name (browser): that backend's tools in upstream order. browser click ranks only inside that backend.

  • Anything else: BM25F ranking. Name tokens weigh most, then parameter names, then the first 600 characters of the description. A name that covers every query word gets a bonus.

  • Reply budget. The reply is compact JSON with full schemas for the top 3 matches only, and stays under 6 KB by trimming long descriptions, then dropping schemas (the caller can ask by exact name), then dropping the lowest entries.

  • No query: the whole catalog grouped by backend, names only, with listed tools marked *.

Listed tools get lossless schema slimming (no $schema, no titles that only repeat the key, no default: null, anyOf [X, null] collapsed to X when that loses nothing). search_tools returns schemas exactly as the upstream sent them.

Resilience and logs

  • tools/list against the upstream has a 5 s budget. On failure or timeout the proxy serves the last-good catalog (in memory, else catalog.json, written 0600 after every successful list). With no catalog at all it lists only the two meta-tools.

  • calls.jsonl gets one line per forwarded call, {ts, tool, via, ok, ms, client}, and one per search, {ts, event: "search", bytes, results, client}. Never arguments, results or query text.

  • client is the client name from the request's _meta envelope, else the HTTP User-Agent.

How it compares

project

what the model sees

how the list is chosen

ToolHive optimizer

find_tool and call_tool only

no direct tools; every call goes through search

mcp-gateway

a static surfaced_tools list plus search

hand-configured

LiteLLM MCP

core_tools surfaced inside search results

hand-configured

this

working set plus search_tools and call_tool, all tools still callable directly

promoted from usage across sessions, with hysteresis, pins and pin decay

Measured on one setup

One private setup (a gateway over several backends, one user, Claude Code). Token counts are estimates (bytes / 4), not tokenizer counts.

before

after

upstream catalog

298 tools, ~529 KB (~132k tokens est.)

listed in tools/list

hand-pinned 27 tools, ~36 KB

working set of 20 tools, ~21 KB (~5.3k tokens est.)

search top-1 (60-query private gold set)

53%

82%

search top-3

72%

98%

largest search reply

118 KB

6 KB

Your numbers will differ; mws report prints yours.

Development

bun install
bun test          # offline: stub upstreams (stateless HTTP, session HTTP, stdio, dead, hung)
bun run typecheck

test/fixtures/catalog.json is a synthetic 60-tool catalog (mail, calendar, drive, browser, codesearch) and test/fixtures/search-gold.json a 50-query gold set for it; bun test prints the top-1 and top-3 hit rates and fails below 70% and 90%.

License

MIT

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    A configurable proxy that filters GitHub MCP server tools via YAML whitelist, reducing context consumption from 100+ tools to only the ones you need while providing meta-tools to discover available functionality.
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Local-first MCP proxy with BM25 tool discovery, quarantine security, Docker isolation, OAuth support, activity logging, and web UI. Routes multiple upstream MCP servers through a single endpoint.
    9
    395
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP proxy that reduces context usage through semantic tool routing, enabling on-demand discovery and routing of relevant tools.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Intelligent MCP proxy server that reduces context bloat by serving only the tools your AI actually needs through semantic search and a fixed two-tool surface.
    13
    MIT