diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index a68cab7f7df9..015dbe49be0c 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -19,6 +19,7 @@ import { getLogs, getMcpCatalog, getMcpOAuthFlow, + getUsageAnalytics, type HermesGateway, installMcpCatalogEntry, type McpCatalogEntry, @@ -27,7 +28,9 @@ import { testMcpServer } from '@/hermes' import { type Translations, useI18n } from '@/i18n' +import { compactNumber } from '@/lib/format' import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' +import { estimateServerTokens, serverUsageCount } from '@/lib/mcp-cost' import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter' import { cn } from '@/lib/utils' @@ -125,6 +128,41 @@ const probeKey = (name: string, server: Record | undefined, pro type Probe = McpTestResult | 'probing' +// Per-server cost/usage overlay inputs: `tokens` is the approximate per-call +// schema cost from the probe (null = no estimate — older backend or no probe +// yet), `uses` is the 30-day analytics call count (null = analytics +// unavailable, so usage is simply omitted). +interface ServerCost { + tokens: null | number + uses: null | number +} + +// 30-day per-tool call counts for the MCP fleet — same shape and TTL rules as +// the Toolsets tab's toolCallsCache (skills/index.tsx), but a 30-day window +// keyed by the Capabilities scope profile. Purely cosmetic: a failed analytics +// fetch caches nothing and the overlay omits usage. +const MCP_USAGE_TTL_MS = 10 * 60_000 +const mcpUsageCache = new Map }>() + +async function loadMcpUsage(scopeKey: string, scopeProfile: null | string): Promise> { + const cached = mcpUsageCache.get(scopeKey) + + if (cached && Date.now() - cached.at < MCP_USAGE_TTL_MS) { + return cached.value + } + + try { + const analytics = await getUsageAnalytics(30, scopeProfile) + const value = Object.fromEntries((analytics.tools ?? []).map(entry => [entry.tool, entry.count])) + mcpUsageCache.set(scopeKey, { at: Date.now(), value }) + + return value + } catch { + // Analytics unavailable — degrade to "no usage shown", never an error UI. + return null + } +} + type ServerStatus = 'off' | 'probing' | 'ok' | 'needs-auth' | 'error' | 'unknown' function statusOf(server: Record, probe: Probe | undefined): ServerStatus { @@ -159,11 +197,13 @@ const STATUS_DOT: Record = { // "12 tools enabled" / "25 tools, 1 prompts, 103 resources enabled" — only // the capabilities the server actually has. When a `server` config is passed, // the tool count reflects the per-tool include/exclude filter (what's actually -// registered), not the raw discovered count. +// registered), not the raw discovered count. The optional `cost` appends the +// overlay — "…, ~4.2k tok, 3 uses/30d" — with each half omitted when unknown. function capabilitySummary( m: Translations['settings']['mcp'], probe: McpTestResult, - server?: Record + server?: Record, + cost?: ServerCost ): string { const toolCount = server ? countEnabledTools( @@ -172,18 +212,29 @@ function capabilitySummary( ) : probe.tools.length - return m.capabilitySummary(toolCount, probe.prompts ?? 0, probe.resources ?? 0) + const parts = [m.capabilitySummary(toolCount, probe.prompts ?? 0, probe.resources ?? 0)] + + if (cost && cost.tokens !== null && cost.tokens > 0) { + parts.push(m.costTokens(compactNumber(cost.tokens))) + } + + if (cost && cost.uses !== null) { + parts.push(m.usage30d(compactNumber(cost.uses))) + } + + return parts.join(', ') } function statusLine( m: Translations['settings']['mcp'], status: ServerStatus, probe: Probe | undefined, - server?: Record + server?: Record, + cost?: ServerCost ): string { switch (status) { case 'ok': - return capabilitySummary(m, probe as McpTestResult, server) + return capabilitySummary(m, probe as McpTestResult, server, cost) case 'probing': return m.statusConnecting @@ -371,6 +422,10 @@ export function McpTab({ gateway, profile }: { gateway: HermesGateway | null; pr const probesRef = useRef(probes) probesRef.current = probes + // 30-day per-tool call counts (registry names). null = analytics unavailable + // or not loaded yet — the cost overlay then omits usage entirely. + const [toolCalls30d, setToolCalls30d] = useState>(null) + // Blocks the browser until an OAuth flow lands a token; also reset on profile // switch, so declared up here alongside the other per-profile view state. const [authing, setAuthing] = useState(null) @@ -515,6 +570,7 @@ export function McpTab({ gateway, profile }: { gateway: HermesGateway | null; pr profileEpoch.current += 1 draftSeeded.current = false setProbes({}) + setToolCalls30d(null) setCursor(0) setAuthing(null) setDirty(false) @@ -669,6 +725,30 @@ export function McpTab({ gateway, profile }: { gateway: HermesGateway | null; pr // eslint-disable-next-line react-hooks/exhaustive-deps }, [servers]) + // Cosmetic 30-day usage counts for the cost overlay — cached module-wide per + // scope profile, epoch-guarded like the probes so a slow profile-A fetch + // can't paint into profile B. + useEffect(() => { + const epoch = profileEpoch.current + + void loadMcpUsage(scopeProfileKey, profile ?? appProfile ?? null).then(value => { + if (profileEpoch.current === epoch) { + setToolCalls30d(value) + } + }) + }, [scopeProfileKey, profile, appProfile]) + + // Overlay inputs for one server: token estimate from its (successful) probe, + // 30-day uses from analytics. Both halves degrade to null independently. + const costFor = (serverName: string, server: Record): ServerCost => { + const probe = probes[serverName] + + return { + tokens: probe && probe !== 'probing' && probe.ok ? estimateServerTokens(server, probe.tools) : null, + uses: toolCalls30d ? serverUsageCount(serverName, toolCalls30d) : null + } + } + // Config writes reach live sessions immediately — no manual "Reload MCP". const silentReload = async () => { if (!gateway) { @@ -952,6 +1032,7 @@ export function McpTab({ gateway, profile }: { gateway: HermesGateway | null; pr {selected && activeEntry ? ( { const server = servers[serverName] const status = statusOf(server, probes[serverName]) + const cost = costFor(serverName, server) return ( focusServer(serverName)} onToggle={checked => void setServerEnabled(serverName, checked)} status={status} - statusText={statusLine(m, status, probes[serverName], server)} + statusText={statusLine(m, status, probes[serverName], server, cost)} + unused={ + serverEnabled(server) && + status === 'ok' && + cost.tokens !== null && + cost.tokens > 0 && + cost.uses === 0 + } /> ) })} @@ -1097,6 +1186,7 @@ export function McpTab({ gateway, profile }: { gateway: HermesGateway | null; pr function ServerConfig({ authing, + cost, description, entry, name, @@ -1111,6 +1201,7 @@ function ServerConfig({ saving }: { authing: boolean + cost?: ServerCost description: null | string entry: Record name: string @@ -1141,7 +1232,7 @@ function ServerConfig({ !hasHeaderAuth && (entry.auth === 'oauth' ? status === 'needs-auth' || status === 'error' : !entry.auth && status === 'needs-auth') - const summary = probe && probe !== 'probing' && probe.ok ? capabilitySummary(m, probe, entry) : null + const summary = probe && probe !== 'probing' && probe.ok ? capabilitySummary(m, probe, entry, cost) : null return ( // p-2 matches the list view's container so flipping list ⇄ config keeps @@ -1621,7 +1712,8 @@ function McpRow({ onSelect, onToggle, status, - statusText + statusText, + unused }: { active: boolean busy: boolean @@ -1633,7 +1725,11 @@ function McpRow({ onToggle: (checked: boolean) => void status: ServerStatus statusText: string + unused?: boolean }) { + const { t } = useI18n() + const m = t.settings.mcp + return (
- + + {prettyName(name)} + + {/* Subtle "paying for schemas, not using them" hint — a muted pill, + never a dialog. Shown only when the overlay KNOWS both halves: + nonzero schema cost and zero 30-day uses. */} + {unused && ( + + {m.unusedPill} + )} - > - {prettyName(name)} {statusText} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 8234860c9d32..dce3b83e470b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1143,7 +1143,9 @@ export function setSkillEnabled( export interface McpTestResult { ok: boolean error?: string - tools: { name: string; description: string }[] + /** `schema_chars` (converted registry-schema size, chars) is additive — + * older backends omit it and the cost overlay shows no token estimate. */ + tools: { name: string; description: string; schema_chars?: number }[] /** Capability counts (absent on older backends / failed probes). */ prompts?: number resources?: number diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0b7f57d461e4..8138cd63f278 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -885,6 +885,9 @@ export const en: Translations = { catalogEnvRequired: 'Fill in the required values before installing.', capabilitySummary: (tools, prompts, resources) => `${[`${tools} tools`, ...(prompts ? [`${prompts} prompts`] : []), ...(resources ? [`${resources} resources`] : [])].join(', ')} enabled`, + costTokens: tokens => `~${tokens} tok/call`, + usage30d: uses => `${uses} uses/30d`, + unusedPill: 'unused', statusConnecting: 'Connecting…', statusNeedsAuth: 'Needs authentication', statusError: 'Error', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a3674007fda7..c75486ff850e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -864,6 +864,9 @@ export const ja = defineLocale({ saveServer: 'サーバーを保存', capabilitySummary: (tools, prompts, resources) => `${[`ツール ${tools} 個`, ...(prompts ? [`プロンプト ${prompts} 個`] : []), ...(resources ? [`リソース ${resources} 個`] : [])].join('、')} を有効化`, + costTokens: tokens => `1 呼び出しあたり約 ${tokens} トークン`, + usage30d: uses => `過去 30 日で ${uses} 回使用`, + unusedPill: '未使用', statusConnecting: '接続中…', statusNeedsAuth: '認証が必要です', statusError: 'エラー', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5ab74d2fa3c0..8d605f024c4d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -758,6 +758,9 @@ export interface Translations { catalogEnvPrompt: (name: string) => string catalogEnvRequired: string capabilitySummary: (tools: number, prompts: number, resources: number) => string + costTokens: (tokens: string) => string + usage30d: (uses: string) => string + unusedPill: string statusConnecting: string statusNeedsAuth: string statusError: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5d5abe0e0746..3fac2813b6db 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -838,6 +838,9 @@ export const zhHant = defineLocale({ saveServer: '儲存伺服器', capabilitySummary: (tools, prompts, resources) => `已啟用 ${[`${tools} 個工具`, ...(prompts ? [`${prompts} 個提示`] : []), ...(resources ? [`${resources} 個資源`] : [])].join('、')}`, + costTokens: tokens => `每次呼叫約 ${tokens} token`, + usage30d: uses => `30 天內 ${uses} 次呼叫`, + unusedPill: '未使用', statusConnecting: '連線中…', statusNeedsAuth: '需要驗證', statusError: '錯誤', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 42deb03e5224..3e6ac5df5a93 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1084,6 +1084,9 @@ export const zh: Translations = { catalogEnvRequired: '安装前请填写必需的值。', capabilitySummary: (tools, prompts, resources) => `已启用 ${[`${tools} 个工具`, ...(prompts ? [`${prompts} 个提示`] : []), ...(resources ? [`${resources} 个资源`] : [])].join('、')}`, + costTokens: tokens => `每次调用约 ${tokens} token`, + usage30d: uses => `30 天内 ${uses} 次调用`, + unusedPill: '未使用', statusConnecting: '连接中…', statusNeedsAuth: '需要认证', statusError: '错误', diff --git a/apps/desktop/src/lib/mcp-cost.test.ts b/apps/desktop/src/lib/mcp-cost.test.ts new file mode 100644 index 000000000000..c0092b051079 --- /dev/null +++ b/apps/desktop/src/lib/mcp-cost.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { estimateServerTokens, mcpServerUsagePrefix, sanitizeMcpNameComponent, serverUsageCount } from './mcp-cost' + +describe('estimateServerTokens', () => { + it('sums ceil(schema_chars / 4) over tools', () => { + const tools = [ + { name: 'a', schema_chars: 400 }, + { name: 'b', schema_chars: 401 } + ] + + // 100 + ceil(100.25) = 100 + 101 + expect(estimateServerTokens({}, tools)).toBe(201) + }) + + it('returns null when no tool carries schema_chars (older backend)', () => { + expect(estimateServerTokens({}, [{ name: 'a' }, { name: 'b', description: 'x' }])).toBe(null) + expect(estimateServerTokens({}, [])).toBe(null) + }) + + it('ignores invalid schema_chars values', () => { + expect( + estimateServerTokens({}, [ + { name: 'a', schema_chars: 0 }, + { name: 'b', schema_chars: -8 }, + { name: 'c', schema_chars: Number.NaN } + ]) + ).toBe(null) + }) + + it('counts only ENABLED tools per the include/exclude filter', () => { + const tools = [ + { name: 'keep', schema_chars: 40 }, + { name: 'drop', schema_chars: 4000 } + ] + + expect(estimateServerTokens({ tools: { exclude: ['drop'] } }, tools)).toBe(10) + expect(estimateServerTokens({ tools: { include: ['keep'] } }, tools)).toBe(10) + // Everything filtered out → no estimate rather than a misleading 0. + expect(estimateServerTokens({ tools: { include: ['other'] } }, tools)).toBe(null) + }) +}) + +describe('name mapping', () => { + it('sanitizes like tools/mcp_tool.py sanitize_mcp_name_component', () => { + expect(sanitizeMcpNameComponent('github-mcp')).toBe('github_mcp') + expect(sanitizeMcpNameComponent('a.b c/d')).toBe('a_b_c_d') + expect(sanitizeMcpNameComponent('plain_ok9')).toBe('plain_ok9') + }) + + it('builds the mcp____ registry prefix', () => { + expect(mcpServerUsagePrefix('linear-app')).toBe('mcp__linear_app__') + }) +}) + +describe('serverUsageCount', () => { + const calls = { + mcp__linear_app__create_issue: 3, + mcp__linear_app__list_issues: 2, + mcp__linear_app__list_resources: 1, + mcp__other__create_issue: 50, + terminal: 900 + } + + it('sums counts across one server, matching sanitized prefix', () => { + expect(serverUsageCount('linear-app', calls)).toBe(6) + expect(serverUsageCount('other', calls)).toBe(50) + }) + + it('returns 0 for servers with no analytics rows', () => { + expect(serverUsageCount('ghost', calls)).toBe(0) + expect(serverUsageCount('linear-app', {})).toBe(0) + }) + + it('does not cross server boundaries on underscore-heavy names', () => { + // `linear` must not swallow `linear_app`'s tools: the double-underscore + // delimiter after the sanitized server name is part of the prefix. + expect(serverUsageCount('linear', calls)).toBe(0) + }) +}) diff --git a/apps/desktop/src/lib/mcp-cost.ts b/apps/desktop/src/lib/mcp-cost.ts new file mode 100644 index 000000000000..0f5a2c8f5302 --- /dev/null +++ b/apps/desktop/src/lib/mcp-cost.ts @@ -0,0 +1,74 @@ +// MCP fleet cost/usage math — pure functions behind the Capabilities page's +// per-server cost overlay ("~4.2k tok, 3 uses/30d"). Display-only: nothing +// here changes what schemas are sent to models. + +import { isToolEnabled } from '@/lib/mcp-tool-filter' + +/** One probed tool as the desktop's /test endpoint reports it. `schema_chars` + * (the JSON-stringified registry schema's length) is additive-optional — + * older backends omit it and the UI simply shows no token estimate. */ +export interface McpProbedTool { + description?: string + name: string + schema_chars?: number +} + +/** + * Approximate per-call prompt-token cost of a server's tool schemas: every + * enabled tool's schema rides along on every model call, so the sum of + * `ceil(schema_chars / 4)` over ENABLED tools (per the server's + * `tools.include`/`tools.exclude` filter) is what the server "adds per call". + * + * Returns `null` when no enabled tool carries `schema_chars` (older backend, + * failed probe, or everything filtered out) — the caller shows no estimate. + */ +export function estimateServerTokens( + server: Record | null | undefined, + tools: McpProbedTool[] +): null | number { + let total = 0 + let sawSchema = false + + for (const tool of tools) { + if (typeof tool.schema_chars !== 'number' || !Number.isFinite(tool.schema_chars) || tool.schema_chars <= 0) { + continue + } + + if (!isToolEnabled(server, tool.name)) { + continue + } + + sawSchema = true + total += Math.ceil(tool.schema_chars / 4) + } + + return sawSchema ? total : null +} + +/** Mirror of `sanitize_mcp_name_component` in tools/mcp_tool.py: hyphens (and + * anything else outside `[A-Za-z0-9_]`) become underscores. */ +export const sanitizeMcpNameComponent = (value: string): string => value.replace(/[^A-Za-z0-9_]/g, '_') + +/** Registry-name prefix for one server's tools. MCP tools register (and show + * up in usage analytics) as `mcp____` — the convention pinned + * by `mcp_prefixed_tool_name` in tools/mcp_tool.py. */ +export const mcpServerUsagePrefix = (serverName: string): string => `mcp__${sanitizeMcpNameComponent(serverName)}__` + +/** + * Sum 30-day analytics call counts across one server's tools (including the + * per-server utility tools like `…__list_resources`, which also carry the + * server prefix). Analytics keys are the registry names, so a prefix match is + * the same grouping the agent's own registry uses. + */ +export function serverUsageCount(serverName: string, toolCalls: Record): number { + const prefix = mcpServerUsagePrefix(serverName) + let total = 0 + + for (const [tool, count] of Object.entries(toolCalls)) { + if (tool.startsWith(prefix) && typeof count === 'number' && Number.isFinite(count)) { + total += count + } + } + + return total +} diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0f5f55ba3596..99ab692fc6cd 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -321,6 +321,29 @@ async def _probe(): if len(desc) > 80: desc = desc[:77] + "..." tools_found.append((t.name, desc)) + if details is not None: + # Per-tool registry-schema sizes so the desktop can estimate the + # per-call token cost a server adds. Uses the SAME converted + # schema the agent registers (name + description + normalized + # parameters) — i.e. what actually rides on every model call. + # Additive-optional wire field: best-effort, absent on failure. + try: + import json as _json + + from tools.mcp_tool import _convert_mcp_schema + + details["schema_chars"] = { + t.name: len( + _json.dumps( + _convert_mcp_schema(name, t), + separators=(",", ":"), + default=str, + ) + ) + for t in server._tools + } + except Exception: # pragma: no cover — display-only extra + pass if details is not None: # Gate the capability probes exactly like runtime utility-tool # registration (tools.mcp_tool._select_utility_schemas): diff --git a/hermes_cli/web_routers/mcp.py b/hermes_cli/web_routers/mcp.py index cddc3c0d11cd..1dd5875a5887 100644 --- a/hermes_cli/web_routers/mcp.py +++ b/hermes_cli/web_routers/mcp.py @@ -210,9 +210,24 @@ def _probe_scoped(): "error": "OAuth authentication required — no token found.", "tools": [], } + # Additive-optional per-tool schema size (chars of the converted registry + # schema) — the desktop's cost overlay estimates tokens from it. Older + # renderers ignore the extra key; failed probes simply omit it. + schema_chars = details.get("schema_chars") or {} return { "ok": True, - "tools": [{"name": t, "description": d} for t, d in tools], + "tools": [ + { + "name": t, + "description": d, + **( + {"schema_chars": schema_chars[t]} + if isinstance(schema_chars.get(t), int) + else {} + ), + } + for t, d in tools + ], "prompts": details.get("prompts", 0), "resources": details.get("resources", 0), } diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index b1d60df0d9da..2182a730cb36 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -166,6 +166,60 @@ def test_mcp_test_oauth_server_without_token_is_not_ok( ) assert resp.json()["ok"] is True + def test_mcp_test_reports_optional_schema_chars( + self, client, isolated_profiles, monkeypatch + ): + """The probe's per-tool `schema_chars` (details out-param) surfaces as an + ADDITIVE per-tool field on the wire; tools without a size stay bare so + older/partial probes degrade to 'no estimate' in the renderer.""" + import hermes_cli.mcp_config as mcp_config + + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n sized-srv:\n url: http://x/mcp\n", + encoding="utf-8", + ) + + def fake_probe(name, config, connect_timeout=30, details=None): + if details is not None: + details["schema_chars"] = {"tool-a": 420} + return [("tool-a", "desc-a"), ("tool-b", "desc-b")] + + monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe) + + resp = client.post( + "/api/mcp/servers/sized-srv/test", params={"profile": "worker_beta"} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + tools = {t["name"]: t for t in body["tools"]} + assert tools["tool-a"]["schema_chars"] == 420 + # No size for tool-b → the key is simply absent (additive-optional). + assert "schema_chars" not in tools["tool-b"] + + def test_mcp_test_without_schema_chars_keeps_old_wire_shape( + self, client, isolated_profiles, monkeypatch + ): + """A probe that never fills schema_chars (older code path) produces the + exact pre-overlay tool objects — nothing new for old renderers.""" + import hermes_cli.mcp_config as mcp_config + + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n plain-srv:\n url: http://x/mcp\n", + encoding="utf-8", + ) + monkeypatch.setattr( + mcp_config, + "_probe_single_server", + lambda name, config, connect_timeout=30, details=None: [("tool-a", "desc")], + ) + + resp = client.post( + "/api/mcp/servers/plain-srv/test", params={"profile": "worker_beta"} + ) + assert resp.status_code == 200 + assert resp.json()["tools"] == [{"name": "tool-a", "description": "desc"}] + class TestProfileScopedModel: def test_model_set_main_scoped(self, client, isolated_profiles):