SLOPSHOPPER

connector-center

Connector control center: how many tokens each MCP connector eats (schemas and replies), its version and what to disable, in the /connectors pane

newpaneguardcommandstatusprocess
v0.1.0MITupdated 2026-10-09narkov/claude-code-connector-center
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · connector-center
│ ┃ Connectors ✕ › fix the failing auth test and add an audit log call │ ┃ r: Refresh u: Check for updates │ ┃ Context ≈97k / 200k · MCP schemas ≈0 in con… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /connectors │ ⎿ connector-center: Connectors: MCP schemas ≈0 in context, 0 conne │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ connector-center: MCP ≈0 in context · 0 connectors

Draws

Pane · Connectors
r: Refresh u: Check for updates Context ≈97k / 200k · MCP schemas ≈0 in context · ≈ estimat…
README

Connector Center for Claude Code

A Claude Code mod that shows how many tokens each MCP connector costs you — and what to switch off.

Every connected MCP server puts its tool schemas into the context window on every turn, and every tool call brings back a reply that stays in the conversation. With ten or twenty connectors that adds up fast, and Claude Code doesn't show you where it goes. /connectors does.

What it shows

  • Schema tokens per connector: what each server's tool definitions cost in context on every turn (●), and what sits deferred until loaded via ToolSearch (○).
  • Reply tokens per tool: calls, total and largest reply this session, plus call history across sessions.
  • Versions: the npm package each npx server runs, the installed version, and whether a newer one exists on npm.
  • What to optimize:
  • connectors that cost ≥1.5k tokens every turn but haven't been called in 14 days (or ever);
  • tools whose replies average ≥6k tokens (ask for limit/fields, or filter in a script);
  • several copies of the same npm server with identical schemas;
  • unpinned npx …@latest servers;
  • configured servers that expose no tools (failed to connect or disabled).

Click a connector (or run /connectors <name>) to see its tools ranked by schema size.

Token figures are estimates (≈4 Latin or ≈2 Cyrillic characters per token), shown with ≈ everywhere.

Install

In a Claude Code terminal session:

/plugin install connector-center --marketplace narkov/claude-code-connector-center

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Use

/connectors            # open the pane: all connectors, ranked by context cost
/connectors ga4        # jump to a connector by name

In the pane: r refresh, u check npm for updates, 0 back to the full list. A status-line entry shows the total MCP schema cost in context.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 2 files
hooks/register.tsx 605 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  ConnectorCalls,
6  ConnectorHistory,
7  ConnectorKind,
8  ConnectorServer,
9  ConnectorTool,
10} from '../types'
11
12const PANE = 'connector-center'
13const TITLE = 'Connectors'
14const STORE_KEY = 'history'
15const DAY = 24 * 60 * 60 * 1000
16const WIDE = 84
17const IDLE_TOKENS = 1500
18const HEAVY_REPLY = 6000
19
20const servers = atom({ plugin: 'connector-center', key: 'servers' } as const, [])
21const context = atom({ plugin: 'connector-center', key: 'context' } as const, null)
22const calls = atom({ plugin: 'connector-center', key: 'calls' } as const, {})
23const history = atom({ plugin: 'connector-center', key: 'history' } as const, {})
24const latest = atom({ plugin: 'connector-center', key: 'latest' } as const, {})
25const checking = atom({ plugin: 'connector-center', key: 'checking' } as const, false)
26const selected = atom({ plugin: 'connector-center', key: 'selected' } as const, null)
27
28type Json = Record<string, unknown>
29type Config = Omit<ConnectorServer, 'tools'>
30
31const obj = (value: unknown): Json =>
32  value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}
33const strings = (value: unknown): string[] =>
34  Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
35
36// Byte-pair tokenizers spend about a token per 4 Latin characters and per 2
37// Cyrillic ones: a rough figure, shown with ≈ everywhere.
38const estimate = (text: string): number => {
39  let ascii = 0
40  let other = 0
41  for (const ch of text) {
42    if (ch.charCodeAt(0) < 128) ascii += 1
43    else other += 1
44  }
45
46  return Math.ceil(ascii / 4 + other / 2)
47}
48
49const fmt = (n: number): string =>
50  n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
51
52const newer = (a: string, b: string): boolean => {
53  const pa = a.split(/[.-]/).map(Number)
54  const pb = b.split(/[.-]/).map(Number)
55  for (let i = 0; i < 3; i += 1) {
56    const x = pa[i] ?? 0
57    const y = pb[i] ?? 0
58    if (x !== y) return x > y
59  }
60
61  return false
62}
63
64const readJson = async ($: EngineInterface, path: string): Promise<Json | null> => {
65  try {
66    return obj(JSON.parse(await $.fs.read(path)))
67  } catch {
68    return null
69  }
70}
71
72const splitSpec = (arg: string): { pkg: string; spec: string | null } => {
73  const at = arg.indexOf('@', arg.startsWith('@') ? 1 : 0)
74
75  return at > 0 ? { pkg: arg.slice(0, at), spec: arg.slice(at + 1) } : { pkg: arg, spec: null }
76}
77
78const dirOf = (path: string): string => path.slice(0, Math.max(0, path.lastIndexOf('/')))
79
80const packageAbove = async (
81  $: EngineInterface,
82  file: string,
83): Promise<{ name: string | null; version: string | null }> => {
84  let dir = dirOf(file)
85  for (let up = 0; up < 4 && dir !== ''; up += 1) {
86    const found = await readJson($, `${dir}/package.json`)
87    if (found !== null) {
88      return {
89        name: typeof found.name === 'string' ? found.name : null,
90        version: typeof found.version === 'string' ? found.version : null,
91      }
92    }
93    dir = dirOf(dir)
94  }
95
96  return { name: null, version: null }
97}
98
99const describe = async (
100  $: EngineInterface,
101  name: string,
102  scope: string,
103  entry: Json,
104  root: string,
105): Promise<Config> => {
106  const base: Config = { name, kind: 'unknown', scope, pkg: null, spec: null, installed: null }
107  const command = typeof entry.command === 'string' ? entry.command : ''
108  const args = strings(entry.args).filter(arg => !arg.startsWith('-'))
109  if (typeof entry.url === 'string' || entry.type === 'http' || entry.type === 'sse') {
110    return { ...base, kind: 'remote' }
111  }
112  if (command.endsWith('npx') && args[0] !== undefined) {
113    const { pkg, spec } = splitSpec(args[0])
114
115    return { ...base, kind: pkg === 'mcp-remote' ? 'remote' : 'npx', pkg, spec }
116  }
117  if (command.endsWith('node')) {
118    const script = args.find(arg => /\.(c|m)?js$/.test(arg))
119    if (script !== undefined) {
120      const path = script.startsWith('/') ? script : `${root}/${script}`
121      const found = await packageAbove($, path)
122
123      return { ...base, kind: 'node', pkg: found.name, installed: found.version }
124    }
125  }
126
127  return { ...base, kind: command === '' ? 'unknown' : 'command' }
128}
129
130const readConfigs = async ($: EngineInterface): Promise<Config[]> => {
131  const root = await $.session.root()
132  const home = (await $.env.get('HOME')) ?? ''
133  const project = obj((await readJson($, `${root}/.mcp.json`))?.mcpServers)
134  const global = await readJson($, `${home}/.claude.json`)
135  const user = obj(global?.mcpServers)
136  const local = obj(obj(obj(global?.projects)[root]).mcpServers)
137
138  const found: Config[] = []
139  for (const [scope, list] of [
140    ['local', local],
141    ['.mcp.json', project],
142    ['user', user],
143  ] as const) {
144    for (const [name, entry] of Object.entries(list)) {
145      if (found.some(c => c.name === name)) continue
146      found.push(await describe($, name, scope, obj(entry), root))
147    }
148  }
149
150  // npx keeps each package it ran under ~/.npm/_npx/<hash>/node_modules.
151  const wanted = [...new Set(found.filter(c => c.kind !== 'node' && c.pkg).map(c => c.pkg as string))]
152  const installed: Record<string, string> = {}
153  let caches: string[] = []
154  try {
155    caches = (await $.fs.list(`${home}/.npm/_npx`)).filter(e => e.kind === 'dir').map(e => e.name)
156  } catch {
157    caches = []
158  }
159  for (const cache of caches) {
160    for (const pkg of wanted) {
161      const meta = await readJson($, `${home}/.npm/_npx/${cache}/node_modules/${pkg}/package.json`)
162      const version = meta?.version
163      if (typeof version !== 'string') continue
164      const known = installed[pkg]
165      if (known === undefined || newer(version, known)) installed[pkg] = version
166    }
167  }
168
169  return found.map(c =>
170    c.kind !== 'node' && c.pkg !== null ? { ...c, installed: installed[c.pkg] ?? null } : c,
171  )
172}
173
174// The configs change rarely and their scan reads the npx cache: done at
175// start and on an explicit refresh, while the token figures follow each turn.
176let configs: Config[] | null = null
177
178const refresh = async ($: EngineInterface, rescan = false): Promise<void> => {
179  if (configs === null || rescan) {
180    try {
181      configs = await readConfigs($)
182    } catch {
183      configs = []
184    }
185  }
186  const usage = await $.session.usage({ breakdown: 'summary' })
187  const breakdown = usage.context.breakdown
188  const byServer = new Map<string, ConnectorTool[]>()
189  for (const tool of breakdown?.mcpTools ?? []) {
190    const list = byServer.get(tool.serverName) ?? []
191    list.push({ name: tool.name, tokens: tool.tokens, isLoaded: tool.isLoaded })
192    byServer.set(tool.serverName, list)
193  }
194
195  const list: ConnectorServer[] = configs.map(c => ({ ...c, tools: byServer.get(c.name) ?? [] }))
196  for (const [name, tools] of byServer) {
197    if (list.some(s => s.name === name)) continue
198    const kind: ConnectorKind = /^claude[._ ]ai[ _]/.test(name)
199      ? 'claude.ai'
200      : name.startsWith('plugin:')
201        ? 'plugin'
202        : 'unknown'
203    list.push({ name, kind, scope: kind, pkg: null, spec: null, installed: null, tools })
204  }
205
206  const mcpLoaded = list.reduce((sum, s) => sum + loadedOf(s), 0)
207  await update($, servers, () => list)
208  await update($, context, () => ({
209    used: breakdown?.totalTokens ?? usage.context.tokens ?? null,
210    window: usage.context.window,
211    mcpLoaded,
212  }))
213  const connected = list.filter(s => s.tools.length > 0).length
214  $.ui.status(`MCP ≈${fmt(mcpLoaded)} in context · ${connected} connectors`)
215}
216
217const loadedOf = (s: ConnectorServer): number =>
218  s.tools.reduce((sum, t) => sum + (t.isLoaded ? t.tokens : 0), 0)
219const deferredOf = (s: ConnectorServer): number =>
220  s.tools.reduce((sum, t) => sum + (t.isLoaded ? 0 : t.tokens), 0)
221
222const callsOf = (s: ConnectorServer, all: Record<string, ConnectorCalls>): ConnectorCalls => {
223  const total: ConnectorCalls = { calls: 0, errors: 0, tokens: 0, max: 0 }
224  const prefix = `mcp__${s.name.replace(/[^A-Za-z0-9_-]/g, '_')}__`
225  for (const [tool, c] of Object.entries(all)) {
226    if (!s.tools.some(t => t.name === tool) && !tool.startsWith(prefix)) continue
227    total.calls += c.calls
228    total.errors += c.errors
229    total.tokens += c.tokens
230    total.max = Math.max(total.max, c.max)
231  }
232
233  return total
234}
235
236const serverOfTool = (tool: string, list: ConnectorServer[]): string => {
237  const owner = list.find(s => s.tools.some(t => t.name === tool))
238  if (owner !== undefined) return owner.name
239  const match = /^mcp__(.+?)__/.exec(tool)
240
241  return match?.[1] ?? tool
242}
243
244const checkUpdates = async ($: EngineInterface): Promise<void> => {
245  await update($, checking, () => true)
246  const list = await read($, servers)
247  const pkgs = [...new Set(list.filter(s => s.kind !== 'node' && s.pkg).map(s => s.pkg as string))]
248  for (const pkg of pkgs) {
249    try {
250      const run = await $.process.run(['npm', 'view', pkg, 'version'], { timeoutMs: 20_000 })
251      const version = run.stdout.trim().split('\n').pop()?.trim()
252      if (run.exitCode === 0 && version) await update($, latest, all => ({ ...all, [pkg]: version }))
253    } catch {
254      // npm missing or offline: leave that package unchecked.
255    }
256  }
257  await update($, checking, () => false)
258}
259
260type Hint = { text: string; color?: string }
261
262const hintsFor = (
263  list: ConnectorServer[],
264  all: Record<string, ConnectorCalls>,
265  past: Record<string, ConnectorHistory>,
266  newest: Record<string, string>,
267  now: number,
268): Hint[] => {
269  const hints: Hint[] = []
270
271  const copies = new Map<string, ConnectorServer[]>()
272  for (const s of list) {
273    if (s.kind !== 'npx' || s.pkg === null) continue
274    copies.set(s.pkg, [...(copies.get(s.pkg) ?? []), s])
275  }
276  for (const [pkg, same] of copies) {
277    if (same.length < 2) continue
278    const schemas = same.reduce((sum, s) => sum + loadedOf(s) + deferredOf(s), 0)
279    const used = same.filter(s => callsOf(s, all).calls > 0 || (past[s.name]?.calls ?? 0) > 0)
280    hints.push({
281      text: `${same.length}× ${pkg}: ≈${fmt(schemas)} tokens of identical schemas, ${used.length} of them called. Keep only the ones this project needs, or run one server that takes the property as an argument.`,
282      color: 'yellow',
283    })
284  }
285
286  for (const s of list) {
287    const here = callsOf(s, all)
288    const before = past[s.name]
289    const loaded = loadedOf(s)
290    const idle = here.calls === 0 && (before === undefined || now - before.lastAt > 14 * DAY)
291    if (loaded >= IDLE_TOKENS && idle) {
292      hints.push({
293        text: `${s.name}: ≈${fmt(loaded)} in context every turn, not called ${before ? 'for over 14 days' : 'even once'}. Disable it in /mcp or move it to the project that needs it.`,
294        color: 'red',
295      })
296    }
297    if (here.calls >= 2 && here.tokens / here.calls >= HEAVY_REPLY) {
298      hints.push({
299        text: `${s.name}: average reply ≈${fmt(Math.round(here.tokens / here.calls))}, largest ≈${fmt(here.max)}. Ask for limit/fields or filter in a script.`,
300        color: 'yellow',
301      })
302    }
303    if (s.kind === 'npx' && (s.spec === null || s.spec === 'latest')) {
304      hints.push({
305        text: `${s.name}: npx ${s.pkg}${s.spec ? '@latest' : ''} is not pinned, the version can change between starts.`,
306      })
307    }
308    const fresh = s.pkg ? newest[s.pkg] : undefined
309    if (fresh !== undefined && s.installed !== null && newer(fresh, s.installed)) {
310      hints.push({ text: `${s.name}: update available ${s.installed} → ${fresh}.`, color: 'cyan' })
311    }
312    if (s.tools.length === 0 && (s.scope === '.mcp.json' || s.scope === 'user' || s.scope === 'local')) {
313      hints.push({ text: `${s.name}: configured but has no tools (failed to connect or disabled).` })
314    }
315  }
316
317  return hints
318}
319
320const versionOf = (s: ConnectorServer, newest: Record<string, string>): string => {
321  if (s.kind === 'claude.ai') return 'claude.ai'
322  if (s.kind === 'remote' && s.pkg === null) return 'remote'
323  const fresh = s.pkg ? newest[s.pkg] : undefined
324  const have = s.installed ?? (s.spec && s.spec !== 'latest' ? s.spec : null)
325  if (have === null) return fresh ? `? → ${fresh}` : '—'
326
327  return fresh !== undefined && newer(fresh, have) ? `${have} → ${fresh}` : have
328}
329
330export const register: Register = on => {
331  on('session.start', async ($, e, next) => {
332    await $.command.register({
333      name: 'connectors',
334      description: "Connector control center: tokens per MCP connector, versions, what to disable",
335    })
336    try {
337      const saved = obj(await $.store.get(STORE_KEY)) as Record<string, ConnectorHistory>
338      await update($, history, () => saved)
339      await refresh($)
340    } catch {
341      $.ui.status(undefined)
342    }
343
344    return next(e)
345  })
346
347  on('turn.complete', async ($, e, next) => {
348    const done = await next(e)
349    if (e.agentId === undefined) {
350      try {
351        await refresh($)
352      } catch {
353        // The figures stay as they were until the next turn.
354      }
355    }
356
357    return done
358  })
359
360  on('tool.call', async ($, e, next) => {
361    const ran = await next(e)
362    const tool = String(e.tool)
363    if (!tool.startsWith('mcp__') || 'deny' in ran) return ran
364    try {
365      const tokens = estimate(ran.text ?? '')
366      const isError = ran.isError === true
367      await update($, calls, all => {
368        const was = all[tool] ?? { calls: 0, errors: 0, tokens: 0, max: 0 }
369
370        return {
371          ...all,
372          [tool]: {
373            calls: was.calls + 1,
374            errors: was.errors + (isError ? 1 : 0),
375            tokens: was.tokens + tokens,
376            max: Math.max(was.max, tokens),
377          },
378        }
379      })
380      const server = serverOfTool(tool, await read($, servers))
381      const now = await $.clock.now()
382      const past = await update($, history, all => {
383        const was = all[server] ?? { calls: 0, tokens: 0, lastAt: 0 }
384
385        return { ...all, [server]: { calls: was.calls + 1, tokens: was.tokens + tokens, lastAt: now } }
386      })
387      await $.store.set(STORE_KEY, past)
388    } catch {
389      // Bookkeeping never costs the call its result.
390    }
391
392    return ran
393  })
394
395  on('command.run', { command: 'connectors' }, async ($, e) => {
396    await refresh($, true)
397    const list = await read($, servers)
398    const all = await read($, calls)
399    const ctx = await read($, context)
400    const want = e.args.trim().toLowerCase()
401    const match = want === '' ? undefined : list.find(s => s.name.toLowerCase().includes(want))
402    await update($, selected, () => match?.name ?? null)
403    await $.ui.open({ id: PANE, title: TITLE, columns: 110, rows: 34, focus: true })
404
405    const ranked = [...list]
406      .sort((a, b) => loadedOf(b) - loadedOf(a) || deferredOf(b) - deferredOf(a))
407      .slice(0, 8)
408
409    return {
410      text: [
411        `Connectors: MCP schemas ≈${fmt(ctx?.mcpLoaded ?? 0)} in context, ${list.length} connectors.`,
412        ...ranked.map(s => {
413          const c = callsOf(s, all)
414
415          return `${s.name}: ${s.tools.length} tools, ≈${fmt(loadedOf(s))} in context, ≈${fmt(deferredOf(s))} deferred, ${c.calls} calls ≈${fmt(c.tokens)}, version ${s.installed ?? '—'}`
416        }),
417      ].join('\n'),
418    }
419  })
420
421  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
422    const { Box, Text, Button } = $.ui.resolve(e)
423    const list = await read($, servers)
424    const all = await read($, calls)
425    const past = await read($, history)
426    const newest = await read($, latest)
427    const busy = await read($, checking)
428    const pick = await read($, selected)
429    const ctx = await read($, context)
430    const columns = e.props.bodyColumns
431    const rows = e.viewport?.rows ?? 24
432    const now = await $.clock.now()
433
434    const controls = (
435      <Box flexDirection="row" gap={2}>
436        {pick !== null && (
437          <Button key="all" hotkey="0" plain onPress={() => void update($, selected, () => null)}>
438            All
439          </Button>
440        )}
441        <Button key="refresh" hotkey="r" plain onPress={() => void refresh($, true)}>
442          Refresh
443        </Button>
444        <Button key="updates" hotkey="u" plain onPress={() => void checkUpdates($)}>
445          {busy ? 'Checking npm…' : 'Check for updates'}
446        </Button>
447      </Box>
448    )
449
450    const header = ctx && (
451      <Text dimColor wrap="truncate-end">
452        Context {ctx.used === null ? '—' : `≈${fmt(ctx.used)}`} / {fmt(ctx.window)} · MCP schemas ≈
453        {fmt(ctx.mcpLoaded)} in context · ≈ estimate, replies are this session
454      </Text>
455    )
456
457    const server = list.find(s => s.name === pick)
458    if (server !== undefined) {
459      const total = callsOf(server, all)
460      const before = past[server.name]
461      const tools = [...server.tools].sort((a, b) => b.tokens - a.tokens)
462      const room = Math.max(1, rows - 12)
463
464      return (
465        <Box flexDirection="column">
466          {controls}
467          <Text bold>{server.name}</Text>
468          <Text dimColor wrap="truncate-end">
469            {server.kind} · {server.scope}
470            {server.pkg ? ` · ${server.pkg}${server.spec ? `@${server.spec}` : ''}` : ''} · version{' '}
471            {versionOf(server, newest)}
472          </Text>
473          <Text>
474            {tools.length} tools · ≈{fmt(loadedOf(server))} in context · ≈{fmt(deferredOf(server))}{' '}
475            deferred
476          </Text>
477          <Text>
478            Session: {total.calls} calls · ≈{fmt(total.tokens)} in replies · max ≈{fmt(total.max)}
479            {total.errors > 0 ? ` · errors ${total.errors}` : ''}
480          </Text>
481          <Text dimColor>
482            All time: {before ? `${before.calls} calls, ≈${fmt(before.tokens)}, last ${Math.round((now - before.lastAt) / DAY)} d ago` : 'no calls yet'}
483          </Text>
484          <Text> </Text>
485          {tools.slice(0, room).map(tool => {
486            const c = all[tool.name]
487
488            return (
489              <Box flexDirection="row">
490                <Box width={8}>
491                  <Text color={tool.isLoaded ? 'yellow' : undefined} dimColor={!tool.isLoaded}>
492                    {tool.isLoaded ? '●' : '○'} {fmt(tool.tokens)}
493                  </Text>
494                </Box>
495                <Text wrap="truncate-end">
496                  {tool.name.replace(/^mcp__.+?__/, '')}
497                  {c ? `  ${c.calls}× ≈${fmt(c.tokens)}` : ''}
498                </Text>
499              </Box>
500            )
501          })}
502          {tools.length > room && <Text dimColor>… {tools.length - room} more</Text>}
503          <Text dimColor>● in context every turn · ○ deferred, loaded via ToolSearch</Text>
504        </Box>
505      )
506    }
507
508    const ranked = [...list].sort(
509      (a, b) =>
510        loadedOf(b) + callsOf(b, all).tokens - (loadedOf(a) + callsOf(a, all).tokens) ||
511        deferredOf(b) - deferredOf(a),
512    )
513    const hints = hintsFor(list, all, past, newest, now)
514    const hintRoom = Math.max(2, Math.floor((rows - 8) / 3))
515    const tableRoom = Math.max(3, rows - 8 - Math.min(hints.length, hintRoom))
516
517    const nameButton = (s: ConnectorServer, index: number) => (
518      <Button
519        key={`server:${s.name}`}
520        label={s.name}
521        hotkey={index < 9 ? String(index + 1) : undefined}
522        plain
523        dimColor={s.tools.length === 0}
524        onPress={() => void update($, selected, () => s.name)}
525      />
526    )
527
528    return (
529      <Box flexDirection="column">
530        {controls}
531        {header}
532        <Text> </Text>
533        {columns >= WIDE && (
534          <Box flexDirection="row">
535            <Box width={26}>
536              <Text dimColor>Connector</Text>
537            </Box>
538            <Box width={7}>
539              <Text dimColor>tools</Text>
540            </Box>
541            <Box width={11}>
542              <Text dimColor>context</Text>
543            </Box>
544            <Box width={10}>
545              <Text dimColor>deferred</Text>
546            </Box>
547            <Box width={14}>
548              <Text dimColor>calls</Text>
549            </Box>
550            <Text dimColor>version</Text>
551          </Box>
552        )}
553        {ranked.slice(0, tableRoom).map((s, index) => {
554          const c = callsOf(s, all)
555          const loaded = loadedOf(s)
556
557          return columns >= WIDE ? (
558            <Box flexDirection="row">
559              <Box width={26}>{nameButton(s, index)}</Box>
560              <Box width={7}>
561                <Text>{s.tools.length}</Text>
562              </Box>
563              <Box width={11}>
564                <Text color={loaded >= IDLE_TOKENS ? 'yellow' : undefined} dimColor={loaded === 0}>
565                  ≈{fmt(loaded)}
566                </Text>
567              </Box>
568              <Box width={10}>
569                <Text dimColor>≈{fmt(deferredOf(s))}</Text>
570              </Box>
571              <Box width={14}>
572                <Text dimColor={c.calls === 0}>
573                  {c.calls}× ≈{fmt(c.tokens)}
574                </Text>
575              </Box>
576              <Text dimColor wrap="truncate-end">
577                {versionOf(s, newest)}
578              </Text>
579            </Box>
580          ) : (
581            <Box flexDirection="column">
582              {nameButton(s, index)}
583              <Text dimColor wrap="truncate-end">
584                {'   '}≈{fmt(loaded)} ctx · ≈{fmt(deferredOf(s))} def · {c.calls}× ≈{fmt(c.tokens)} ·{' '}
585                {versionOf(s, newest)}
586              </Text>
587            </Box>
588          )
589        })}
590        {ranked.length > tableRoom && <Text dimColor>… {ranked.length - tableRoom} more</Text>}
591        {hints.length > 0 && <Text> </Text>}
592        {hints.length > 0 && <Text bold>What to optimize</Text>}
593        {hints.slice(0, hintRoom).map(hint =>
594          hint.color === undefined ? (
595            <Text dimColor>• {hint.text}</Text>
596          ) : (
597            <Text color={hint.color}>• {hint.text}</Text>
598          ),
599        )}
600        {hints.length > hintRoom && <Text dimColor>… {hints.length - hintRoom} more tips</Text>}
601      </Box>
602    )
603  })
604}
605
types/index.d.ts 39 lines
1export type ConnectorKind = 'npx' | 'node' | 'command' | 'remote' | 'claude.ai' | 'plugin' | 'unknown'
2
3export type ConnectorTool = { name: string; tokens: number; isLoaded: boolean }
4
5export type ConnectorServer = {
6  name: string
7  kind: ConnectorKind
8  // Where it is configured: .mcp.json, user, local, claude.ai, plugin.
9  scope: string
10  // The npm package it runs, when there is one, and the version spec it asks
11  // npx for (`latest`, `1.2.3`, or null when unpinned).
12  pkg: string | null
13  spec: string | null
14  installed: string | null
15  tools: ConnectorTool[]
16}
17
18export type ConnectorCalls = { calls: number; errors: number; tokens: number; max: number }
19
20export type ConnectorContext = { used: number | null; window: number; mcpLoaded: number }
21
22export type ConnectorHistory = { calls: number; tokens: number; lastAt: number }
23
24declare module 'claude-code' {
25  interface PluginState {
26    'connector-center': {
27      servers: ConnectorServer[]
28      context: ConnectorContext | null
29      // By tool name (mcp__server__tool), this session.
30      calls: Record<string, ConnectorCalls>
31      // By server name, across sessions (mirrors $.store).
32      history: Record<string, ConnectorHistory>
33      latest: Record<string, string>
34      checking: boolean
35      selected: string | null
36    }
37  }
38}
39