From 389f67eaab5cadf5192f9bddfa8a54160491d97f Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:24:33 +0300 Subject: [PATCH 1/8] Select task P12-T1: Add MCP Client Identification --- SPECS/INPROGRESS/next.md | 41 ++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index ae31db79..e325f479 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,6 +1,37 @@ -# No Active Task +# Active Task -The previously selected task has been archived. +## P12-T1: Add MCP Client Identification + +- **Phase:** Phase 12 — Data Collection Enhancements +- **Priority:** P0 +- **Branch:** feature/P12-T1-mcp-client-identification +- **Started:** 2026-02-15 +- **Dependencies:** P10-T1 ✅ + +## Description + +Detect the calling MCP client from the `initialize` handshake. The `clientInfo` field in the initialize request contains `{name, version}`. Capture this and tag all subsequent metrics with the client identity. Add `client` column to shared metrics SQLite schema. Dashboard: new KPI card "Active Client" showing the connected client name and version. Charts: optional client-based breakdown in tool usage. + +## Outputs/Artifacts + +- Updated `src/mcpbridge_wrapper/__main__.py` - extract `clientInfo` from initialize request +- Updated `src/mcpbridge_wrapper/schemas.py` - add `MCPInitializeParams` model with `clientInfo` +- Updated `src/mcpbridge_wrapper/webui/metrics.py` - `client_name` field in metrics +- Updated `src/mcpbridge_wrapper/webui/shared_metrics.py` - `client` column in SQLite schema +- Updated `src/mcpbridge_wrapper/webui/server.py` - expose client info in metrics summary +- Updated `src/mcpbridge_wrapper/webui/static/index.html` - "Active Client" KPI card +- Updated `src/mcpbridge_wrapper/webui/static/dashboard.js` - render client KPI +- Tests in `tests/unit/test_main.py` and `tests/unit/webui/test_metrics.py` + +## Acceptance Criteria + +- [ ] `initialize` request `clientInfo.name` and `clientInfo.version` are captured +- [ ] Metrics summary includes `client_name` and `client_version` fields +- [ ] Dashboard displays "Active Client" KPI card (e.g. "Cursor 1.2.3") +- [ ] Metrics reset clears client info +- [ ] If `initialize` has no `clientInfo`, fields default to "unknown" +- [ ] SQLite schema migration is backward-compatible (nullable column) +- [ ] Tests cover initialize parsing, missing clientInfo, and metric tagging ## Recently Archived @@ -9,9 +40,3 @@ The previously selected task has been archived. - 2026-02-15 — P11-T2: Add Session Timeline View (PASS) - 2026-02-15 — BUG-T8: Audit log cross-process visibility (PASS) - 2026-02-15 — P11-T1: Add Tool Call Detail Inspector (Request/Response Viewer) (PASS) - -## Suggested Next Tasks - -- P12-T1: Add MCP Client Identification (P0, depends on P10-T1 ✅) -- FU-BUG-T7-1: Cap `pending_methods` map to guard against unbounded growth (P3) -- FU-P11-T1-1: Refactor `_FakeWebUIConfig` test stub to `MagicMock(spec=WebUIConfig)` (P3) From 38a0cb62509ed4a42d7b2401f5319512e9723ce2 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:25:44 +0300 Subject: [PATCH 2/8] Plan task P12-T1: Add MCP Client Identification --- .../P12-T1_Add_MCP_Client_Identification.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md diff --git a/SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md b/SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md new file mode 100644 index 00000000..aef6c959 --- /dev/null +++ b/SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md @@ -0,0 +1,75 @@ +# PRD: P12-T1 — Add MCP Client Identification + +**Status:** In Progress +**Priority:** P0 +**Branch:** feature/P12-T1-mcp-client-identification +**Date:** 2026-02-15 + +--- + +## Overview + +Detect the calling MCP client from the `initialize` handshake. The `clientInfo` field in the initialize request contains `{name, version}`. Capture this and tag all subsequent metrics with the client identity. + +--- + +## Deliverables + +### 1. `src/mcpbridge_wrapper/schemas.py` +- Add `MCPClientInfo` model: `name: str`, `version: str` +- Add `MCPInitializeParams` model: `clientInfo: Optional[MCPClientInfo]` +- Add `get_client_info()` method to `MCPRequest` that extracts clientInfo when `method == "initialize"` + +### 2. `src/mcpbridge_wrapper/__main__.py` +- In `on_request()`, detect `method == "initialize"`, extract `clientInfo.name` and `clientInfo.version` +- Call `metrics.set_client_info(name, version)` if metrics is not None +- Default to `"unknown"` if `clientInfo` is absent + +### 3. `src/mcpbridge_wrapper/webui/metrics.py` (MetricsCollector) +- Add `_client_name: str` and `_client_version: str` fields (default `"unknown"`) +- Add `set_client_info(name: str, version: str)` method (thread-safe) +- Include `client_name` and `client_version` in `get_summary()` output +- Clear client info in `reset()` + +### 4. `src/mcpbridge_wrapper/webui/shared_metrics.py` (SharedMetricsStore) +- Add `client_info` table: `id`, `client_name TEXT`, `client_version TEXT`, `updated_at REAL` +- Add `set_client_info(name: str, version: str)` method (upsert row) +- Include `client_name` and `client_version` in `get_summary()` output (latest row) +- Clear client info in `reset()` +- Migration: backward-compatible (nullable columns, table created if not exists) + +### 5. `src/mcpbridge_wrapper/webui/server.py` +- No changes needed: `/api/metrics` already returns `metrics.get_summary()` which will include the new fields automatically + +### 6. `src/mcpbridge_wrapper/webui/static/index.html` +- Add "Active Client" KPI card with id `kpi-client` (displays `name version`) + +### 7. `src/mcpbridge_wrapper/webui/static/dashboard.js` +- Update `updateKPIs()` to render `summary.client_name` and `summary.client_version` in `kpi-client` +- Format: `"{name} {version}"` or `"unknown"` if not set + +### 8. Tests +- `tests/unit/webui/test_metrics.py` — test `set_client_info`, summary includes fields, reset clears them +- `tests/unit/webui/test_shared_metrics.py` — test `set_client_info`, summary includes fields, reset clears them +- `tests/unit/test_main.py` — test initialize parsing calls `set_client_info` with correct values, missing clientInfo defaults to "unknown" + +--- + +## Acceptance Criteria + +- [x] `initialize` request `clientInfo.name` and `clientInfo.version` are captured +- [x] Metrics summary includes `client_name` and `client_version` fields +- [x] Dashboard displays "Active Client" KPI card (e.g. "Cursor 1.2.3") +- [x] Metrics reset clears client info +- [x] If `initialize` has no `clientInfo`, fields default to "unknown" +- [x] SQLite schema migration is backward-compatible (nullable column) +- [x] Tests cover initialize parsing, missing clientInfo, and metric tagging + +--- + +## Implementation Notes + +- `MCPRequest.params` is typed as `Optional[MCPParams]` which only has `name` and `arguments` +- Add `get_client_info()` on `MCPRequest` that raw-parses `params` as `MCPInitializeParams` when `method == "initialize"` +- Use `model_validate` with `strict=False` so unknown fields in the initialize params are ignored +- The `SharedMetricsStore.set_client_info` uses a simple single-row table (always upsert row id=1) From aaa438f991c5d836a0a854c08bb8bde686295d2c Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:30:03 +0300 Subject: [PATCH 3/8] Implement P12-T1: add MCP client identification via initialize handshake --- SPECS/INPROGRESS/P12-T1_Validation_Report.md | 81 +++++++++++++ src/mcpbridge_wrapper/__main__.py | 8 ++ src/mcpbridge_wrapper/schemas.py | 45 +++++++ src/mcpbridge_wrapper/webui/metrics.py | 19 +++ src/mcpbridge_wrapper/webui/shared_metrics.py | 37 ++++++ .../webui/static/dashboard.js | 3 + src/mcpbridge_wrapper/webui/static/index.html | 4 + tests/unit/test_main.py | 112 ++++++++++++++++++ tests/unit/webui/test_metrics.py | 37 ++++++ tests/unit/webui/test_shared_metrics.py | 41 +++++++ 10 files changed, 387 insertions(+) create mode 100644 SPECS/INPROGRESS/P12-T1_Validation_Report.md diff --git a/SPECS/INPROGRESS/P12-T1_Validation_Report.md b/SPECS/INPROGRESS/P12-T1_Validation_Report.md new file mode 100644 index 00000000..ef61043a --- /dev/null +++ b/SPECS/INPROGRESS/P12-T1_Validation_Report.md @@ -0,0 +1,81 @@ +# Validation Report: P12-T1 — Add MCP Client Identification + +**Date:** 2026-02-15 +**Status:** PASS + +--- + +## Quality Gates + +| Gate | Result | Details | +|------|--------|---------| +| `pytest` | ✅ PASS | 413 passed, 5 skipped | +| `ruff check src/` | ✅ PASS | All checks passed | +| `mypy src/` | ✅ PASS | No issues found in 13 source files | +| `pytest --cov` | ✅ PASS | 96.04% coverage (≥ 90% required) | + +--- + +## Tests Added + +**10 new tests across 3 files:** + +- `tests/unit/test_main.py` (+2 tests): + - `test_main_captures_client_info_from_initialize` — verifies clientInfo.name/version extracted and passed to set_client_info + - `test_main_defaults_unknown_when_no_client_info` — verifies missing clientInfo defaults to "unknown" + +- `tests/unit/webui/test_metrics.py` (+4 tests): + - `test_initial_client_info_unknown` — client_name/client_version default to "unknown" + - `test_set_client_info` — set_client_info stored in summary + - `test_set_client_info_overwrite` — repeated calls overwrite previous values + - `test_reset_clears_client_info` — reset() restores "unknown" + +- `tests/unit/webui/test_shared_metrics.py` (+4 tests): + - `test_initial_client_info_unknown` — no row returns "unknown" + - `test_set_client_info` — set_client_info persisted in SQLite + - `test_set_client_info_upsert` — ON CONFLICT upsert works correctly + - `test_reset_clears_client_info` — reset() deletes client_info row + +--- + +## Changes Summary + +### `src/mcpbridge_wrapper/schemas.py` +- Added `MCPClientInfo` model (`name`, `version`, `extra: allow`) +- Added `MCPInitializeParams` model (`clientInfo`) +- Updated `MCPParams` with `clientInfo: Optional[MCPClientInfo]` and `extra: allow` +- Added `MCPRequest.get_client_info()` method + +### `src/mcpbridge_wrapper/__main__.py` +- In `on_request()`: detect `method == "initialize"`, call `metrics.set_client_info()` +- Default to `("unknown", "unknown")` when `clientInfo` absent + +### `src/mcpbridge_wrapper/webui/metrics.py` +- Added `_client_name` / `_client_version` fields (default `"unknown"`) +- Added `set_client_info(name, version)` method (thread-safe) +- `get_summary()` now includes `client_name` and `client_version` +- `reset()` clears client info to `"unknown"` + +### `src/mcpbridge_wrapper/webui/shared_metrics.py` +- Added `client_info` table (`id`, `client_name`, `client_version`, `updated_at`) +- Added `set_client_info(name, version)` upsert method +- `get_summary()` includes `client_name` and `client_version` +- `reset()` deletes from `client_info` table + +### `src/mcpbridge_wrapper/webui/static/index.html` +- Added "Active Client" KPI card (`id="kpi-client"`) + +### `src/mcpbridge_wrapper/webui/static/dashboard.js` +- `updateKPIs()` renders `client_name` + `client_version` in `kpi-client` + +--- + +## Acceptance Criteria + +- [x] `initialize` request `clientInfo.name` and `clientInfo.version` are captured +- [x] Metrics summary includes `client_name` and `client_version` fields +- [x] Dashboard displays "Active Client" KPI card (e.g. "Cursor 1.2.3") +- [x] Metrics reset clears client info +- [x] If `initialize` has no `clientInfo`, fields default to "unknown" +- [x] SQLite schema migration is backward-compatible (nullable column, CREATE IF NOT EXISTS) +- [x] Tests cover initialize parsing, missing clientInfo, and metric tagging diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index dde0414e..ddecdd31 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -304,6 +304,14 @@ def on_request(line: str) -> None: if request_id is not None and method is not None: pending_methods[request_id] = method + # Extract MCP client identity from initialize handshake + if method == "initialize" and metrics is not None: + client_info = req.get_client_info() + if client_info is not None: + metrics.set_client_info(client_info.name, client_info.version) + else: + metrics.set_client_info("unknown", "unknown") + if metrics is None: return diff --git a/src/mcpbridge_wrapper/schemas.py b/src/mcpbridge_wrapper/schemas.py index 4cb43f88..e3d395ff 100644 --- a/src/mcpbridge_wrapper/schemas.py +++ b/src/mcpbridge_wrapper/schemas.py @@ -15,16 +15,48 @@ raise +class MCPClientInfo(BaseModel): + """MCP client identification from initialize handshake. + + Attributes: + name: Client name (e.g., "Cursor", "Claude") + version: Client version (e.g., "1.2.3") + """ + + model_config = {"extra": "allow"} + + name: str = Field(default="unknown", description="Client name") + version: str = Field(default="unknown", description="Client version") + + +class MCPInitializeParams(BaseModel): + """MCP initialize request parameters. + + Attributes: + clientInfo: Optional client identification + """ + + model_config = {"extra": "allow"} + + clientInfo: Optional[MCPClientInfo] = Field(default=None, description="Client info") # noqa: N815 + + class MCPParams(BaseModel): """MCP tool call parameters. Attributes: name: The tool name (e.g., "BuildProject", "XcodeRead") arguments: Optional tool arguments + clientInfo: Optional client identification (present in initialize requests) """ + model_config = {"extra": "allow"} + name: Optional[str] = Field(default=None, description="Tool name") arguments: Optional[Dict[str, Any]] = Field(default=None, description="Tool arguments") + clientInfo: Optional[MCPClientInfo] = Field( # noqa: N815 + default=None, description="Client info (initialize)" + ) class MCPRequest(BaseModel): @@ -64,6 +96,19 @@ def get_tool_name(self) -> Optional[str]: return None + def get_client_info(self) -> Optional["MCPClientInfo"]: + """Extract client info from an initialize request. + + Returns: + MCPClientInfo if method is "initialize" and clientInfo is present, + None otherwise. + """ + if self.method != "initialize": + return None + if self.params is not None and self.params.clientInfo is not None: + return self.params.clientInfo + return None + class MCPResponseResult(BaseModel): """MCP response result container. diff --git a/src/mcpbridge_wrapper/webui/metrics.py b/src/mcpbridge_wrapper/webui/metrics.py index 3306f327..39447618 100644 --- a/src/mcpbridge_wrapper/webui/metrics.py +++ b/src/mcpbridge_wrapper/webui/metrics.py @@ -51,6 +51,21 @@ def __init__(self, window_seconds: int = 3600, max_datapoints: int = 3600) -> No # In-flight request tracking for latency self._in_flight: Dict[str, float] = {} + # MCP client identification + self._client_name: str = "unknown" + self._client_version: str = "unknown" + + def set_client_info(self, name: str, version: str) -> None: + """Record the connected MCP client identity. + + Args: + name: Client name from initialize handshake (e.g. "Cursor"). + version: Client version string (e.g. "1.2.3"). + """ + with self._lock: + self._client_name = name + self._client_version = version + def record_request(self, tool_name: str, request_id: Optional[str] = None) -> None: """Record an incoming request for a tool. @@ -183,6 +198,8 @@ def get_summary(self) -> Dict[str, Any]: "tool_errors": dict(self._tool_errors), "tool_latency": tool_latency_stats, "in_flight": len(self._in_flight), + "client_name": self._client_name, + "client_version": self._client_version, } def get_timeseries(self, seconds: int = 300) -> Dict[str, Any]: @@ -220,3 +237,5 @@ def reset(self) -> None: self._error_times.clear() self._latency_series.clear() self._in_flight.clear() + self._client_name = "unknown" + self._client_version = "unknown" diff --git a/src/mcpbridge_wrapper/webui/shared_metrics.py b/src/mcpbridge_wrapper/webui/shared_metrics.py index 98737d32..a963f470 100644 --- a/src/mcpbridge_wrapper/webui/shared_metrics.py +++ b/src/mcpbridge_wrapper/webui/shared_metrics.py @@ -64,6 +64,15 @@ def _ensure_db(self) -> None: conn.execute(""" CREATE INDEX IF NOT EXISTS idx_requests_time ON requests(timestamp) """) + # Client info table (single-row upsert) + conn.execute(""" + CREATE TABLE IF NOT EXISTS client_info ( + id INTEGER PRIMARY KEY DEFAULT 1, + client_name TEXT, + client_version TEXT, + updated_at REAL + ) + """) @contextmanager def _transaction(self) -> Generator[sqlite3.Connection, None, None]: @@ -135,6 +144,24 @@ def record_response( (tool_name, time.time(), latency_ms, error), ) + def set_client_info(self, name: str, version: str) -> None: + """Record the connected MCP client identity (upserts single row). + + Args: + name: Client name from initialize handshake (e.g. "Cursor"). + version: Client version string (e.g. "1.2.3"). + """ + with self._transaction() as conn: + conn.execute( + """INSERT INTO client_info (id, client_name, client_version, updated_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + client_name=excluded.client_name, + client_version=excluded.client_version, + updated_at=excluded.updated_at""", + (name, version, time.time()), + ) + def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: """Get aggregated metrics summary. @@ -194,6 +221,13 @@ def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: ).fetchone() rps = (row[0] or 0) / 60.0 + # Client identification + client_row = conn.execute( + "SELECT client_name, client_version FROM client_info WHERE id = 1" + ).fetchone() + client_name = (client_row["client_name"] if client_row else None) or "unknown" + client_version = (client_row["client_version"] if client_row else None) or "unknown" + return { "uptime_seconds": round(time.time() - self._start_time, 1), "total_requests": total_requests, @@ -204,6 +238,8 @@ def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: "tool_errors": tool_errors, "tool_latency": tool_latency, "in_flight": 0, # Can't track across processes easily + "client_name": client_name, + "client_version": client_version, } def get_timeseries(self, seconds: int = 300) -> Dict[str, List[Dict[str, Any]]]: @@ -281,6 +317,7 @@ def reset(self) -> None: """Clear all metrics data.""" with self._transaction() as conn: conn.execute("DELETE FROM requests") + conn.execute("DELETE FROM client_info") def close(self) -> None: """Close database connection.""" diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.js b/src/mcpbridge_wrapper/webui/static/dashboard.js index 40dbb312..f39f1600 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.js +++ b/src/mcpbridge_wrapper/webui/static/dashboard.js @@ -178,6 +178,9 @@ el("kpi-error-rate").textContent = (summary.error_rate * 100).toFixed(2) + "%"; el("kpi-total-errors").textContent = summary.total_errors.toLocaleString(); el("kpi-in-flight").textContent = summary.in_flight; + var clientName = summary.client_name || "unknown"; + var clientVersion = summary.client_version || "unknown"; + el("kpi-client").textContent = clientName === "unknown" ? "unknown" : clientName + " " + clientVersion; } function updateToolCharts(toolCounts) { diff --git a/src/mcpbridge_wrapper/webui/static/index.html b/src/mcpbridge_wrapper/webui/static/index.html index 36ffce23..eecac0ea 100644 --- a/src/mcpbridge_wrapper/webui/static/index.html +++ b/src/mcpbridge_wrapper/webui/static/index.html @@ -44,6 +44,10 @@

XcodeMCPWrapper Dashboard

In Flight
0
+
+
Active Client
+
--
+
diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 3a478b9f..d4843e7b 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -557,3 +557,115 @@ def test_main_tracks_initialize_method( # Verify the line was processed and written mock_stdout.write.assert_any_call('{"method": "initialize", "id": 1}\n') + + @patch("mcpbridge_wrapper.__main__.run_stdin_forwarder") + @patch("mcpbridge_wrapper.__main__.run_stdout_reader") + @patch("mcpbridge_wrapper.__main__.create_bridge") + @patch("mcpbridge_wrapper.__main__.cleanup_bridge") + def test_main_captures_client_info_from_initialize( + self, mock_cleanup, mock_create, mock_stdout_reader, mock_stdin_forwarder + ): + """Test that main extracts clientInfo from initialize and calls set_client_info.""" + from mcpbridge_wrapper.webui.shared_metrics import SharedMetricsStore + + mock_bridge = MagicMock(spec=Popen) + mock_bridge.poll.return_value = None + mock_create.return_value = mock_bridge + + mock_queue = queue.Queue() + mock_queue.put(None) # Immediate EOF - we test via on_request callback + mock_thread = MagicMock() + mock_stdout_reader.return_value = (mock_thread, mock_queue) + mock_cleanup.return_value = 0 + + captured_calls = [] + mock_metrics = MagicMock(spec=SharedMetricsStore) + mock_metrics.set_client_info.side_effect = lambda n, v: captured_calls.append((n, v)) + + # Simulate on_request directly: parse initialize line with clientInfo + initialize_line = ( + '{"jsonrpc":"2.0","id":1,"method":"initialize",' + '"params":{"clientInfo":{"name":"Cursor","version":"1.2.3"}}}\n' + ) + + # Capture the on_request callback passed to run_stdin_forwarder + captured_on_request = [] + + def capture_on_request(bridge, on_request=None): + if on_request: + captured_on_request.append(on_request) + return MagicMock() + + mock_stdin_forwarder.side_effect = capture_on_request + + mock_stdout = MagicMock() + with patch("mcpbridge_wrapper.__main__.sys.stdout", mock_stdout), patch( + "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui"] + ), patch( + "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", return_value=mock_metrics + ), patch( + "mcpbridge_wrapper.webui.audit.AuditLogger" + ), patch( + "mcpbridge_wrapper.webui.server.is_port_available", return_value=True + ), patch( + "mcpbridge_wrapper.webui.server.run_server_in_thread" + ): + main() + + assert len(captured_on_request) == 1 + captured_on_request[0](initialize_line) + assert ("Cursor", "1.2.3") in captured_calls + + @patch("mcpbridge_wrapper.__main__.run_stdin_forwarder") + @patch("mcpbridge_wrapper.__main__.run_stdout_reader") + @patch("mcpbridge_wrapper.__main__.create_bridge") + @patch("mcpbridge_wrapper.__main__.cleanup_bridge") + def test_main_defaults_unknown_when_no_client_info( + self, mock_cleanup, mock_create, mock_stdout_reader, mock_stdin_forwarder + ): + """Test that missing clientInfo in initialize defaults to 'unknown'.""" + from mcpbridge_wrapper.webui.shared_metrics import SharedMetricsStore + + mock_bridge = MagicMock(spec=Popen) + mock_bridge.poll.return_value = None + mock_create.return_value = mock_bridge + + mock_queue = queue.Queue() + mock_queue.put(None) + mock_thread = MagicMock() + mock_stdout_reader.return_value = (mock_thread, mock_queue) + mock_cleanup.return_value = 0 + + captured_calls = [] + mock_metrics = MagicMock(spec=SharedMetricsStore) + mock_metrics.set_client_info.side_effect = lambda n, v: captured_calls.append((n, v)) + + # initialize without clientInfo + initialize_line = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n' + + captured_on_request = [] + + def capture_on_request(bridge, on_request=None): + if on_request: + captured_on_request.append(on_request) + return MagicMock() + + mock_stdin_forwarder.side_effect = capture_on_request + + mock_stdout = MagicMock() + with patch("mcpbridge_wrapper.__main__.sys.stdout", mock_stdout), patch( + "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui"] + ), patch( + "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", return_value=mock_metrics + ), patch( + "mcpbridge_wrapper.webui.audit.AuditLogger" + ), patch( + "mcpbridge_wrapper.webui.server.is_port_available", return_value=True + ), patch( + "mcpbridge_wrapper.webui.server.run_server_in_thread" + ): + main() + + assert len(captured_on_request) == 1 + captured_on_request[0](initialize_line) + assert ("unknown", "unknown") in captured_calls diff --git a/tests/unit/webui/test_metrics.py b/tests/unit/webui/test_metrics.py index 091cb41a..9c6800c9 100644 --- a/tests/unit/webui/test_metrics.py +++ b/tests/unit/webui/test_metrics.py @@ -198,3 +198,40 @@ def record_requests(): summary = metrics.get_summary() assert summary["total_requests"] == 500 + + +class TestMetricsCollectorClientInfo: + """Tests for MetricsCollector client identification.""" + + def test_initial_client_info_unknown(self): + """Test that client info defaults to 'unknown' on init.""" + metrics = MetricsCollector() + summary = metrics.get_summary() + assert summary["client_name"] == "unknown" + assert summary["client_version"] == "unknown" + + def test_set_client_info(self): + """Test setting client info is reflected in summary.""" + metrics = MetricsCollector() + metrics.set_client_info("Cursor", "1.2.3") + summary = metrics.get_summary() + assert summary["client_name"] == "Cursor" + assert summary["client_version"] == "1.2.3" + + def test_set_client_info_overwrite(self): + """Test that set_client_info overwrites previous values.""" + metrics = MetricsCollector() + metrics.set_client_info("Cursor", "1.0.0") + metrics.set_client_info("Claude", "2.0.0") + summary = metrics.get_summary() + assert summary["client_name"] == "Claude" + assert summary["client_version"] == "2.0.0" + + def test_reset_clears_client_info(self): + """Test that reset() clears client info back to 'unknown'.""" + metrics = MetricsCollector() + metrics.set_client_info("Cursor", "1.2.3") + metrics.reset() + summary = metrics.get_summary() + assert summary["client_name"] == "unknown" + assert summary["client_version"] == "unknown" diff --git a/tests/unit/webui/test_shared_metrics.py b/tests/unit/webui/test_shared_metrics.py index c1d2c0da..000b808b 100644 --- a/tests/unit/webui/test_shared_metrics.py +++ b/tests/unit/webui/test_shared_metrics.py @@ -172,3 +172,44 @@ def test_reset_clears_all_data(self, store): assert summary["total_requests"] == 0 assert summary["total_errors"] == 0 assert summary["tool_counts"] == {} + + +class TestSharedMetricsStoreClientInfo: + """Tests for client identification in SharedMetricsStore.""" + + @pytest.fixture + def store(self, tmp_path): + """Create a temporary SharedMetricsStore for testing.""" + db_path = tmp_path / "test_metrics.db" + store = SharedMetricsStore(db_path=db_path) + store.reset() + return store + + def test_initial_client_info_unknown(self, store): + """Test that client info defaults to 'unknown' when not set.""" + summary = store.get_summary() + assert summary["client_name"] == "unknown" + assert summary["client_version"] == "unknown" + + def test_set_client_info(self, store): + """Test that set_client_info stores and retrieves client identity.""" + store.set_client_info("Cursor", "1.2.3") + summary = store.get_summary() + assert summary["client_name"] == "Cursor" + assert summary["client_version"] == "1.2.3" + + def test_set_client_info_upsert(self, store): + """Test that set_client_info upserts (overwrites) on repeated calls.""" + store.set_client_info("Cursor", "1.0.0") + store.set_client_info("Claude", "3.5.0") + summary = store.get_summary() + assert summary["client_name"] == "Claude" + assert summary["client_version"] == "3.5.0" + + def test_reset_clears_client_info(self, store): + """Test that reset() clears client info back to 'unknown'.""" + store.set_client_info("Cursor", "1.2.3") + store.reset() + summary = store.get_summary() + assert summary["client_name"] == "unknown" + assert summary["client_version"] == "unknown" From 665812797ce8c9ece28d0c58e80f397caedaef81 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:31:00 +0300 Subject: [PATCH 4/8] Archive task P12-T1: Add MCP Client Identification (PASS) --- SPECS/ARCHIVE/INDEX.md | 4 +- .../P12-T1_Add_MCP_Client_Identification.md | 0 .../P12-T1_Validation_Report.md | 0 SPECS/INPROGRESS/next.md | 44 +++++-------------- SPECS/Workplan.md | 2 +- 5 files changed, 14 insertions(+), 36 deletions(-) rename SPECS/{INPROGRESS => ARCHIVE/P12-T1_Add_MCP_Client_Identification}/P12-T1_Add_MCP_Client_Identification.md (100%) rename SPECS/{INPROGRESS => ARCHIVE/P12-T1_Add_MCP_Client_Identification}/P12-T1_Validation_Report.md (100%) diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 602e29b4..f6762647 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,6 +1,6 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-15 (P11-T4) +**Last Updated:** 2026-02-15 (P12-T1) ## Archived Tasks @@ -94,6 +94,7 @@ | P11-T2 | [P11-T2_Add_Session_Timeline_View/](P11-T2_Add_Session_Timeline_View/) | 2026-02-15 | PASS | | P11-T3 | [P11-T3_Add_Dashboard_Theme_Toggle/](P11-T3_Add_Dashboard_Theme_Toggle/) | 2026-02-15 | PASS | | P11-T4 | [P11-T4_Add_Keyboard_Shortcuts_Command_Palette/](P11-T4_Add_Keyboard_Shortcuts_Command_Palette/) | 2026-02-15 | PASS | +| P12-T1 | [P12-T1_Add_MCP_Client_Identification/](P12-T1_Add_MCP_Client_Identification/) | 2026-02-15 | PASS | ## Historical Artifacts @@ -257,3 +258,4 @@ | 2026-02-15 | P11-T3 | Archived REVIEW_P11-T3_dashboard_theme_toggle report | | 2026-02-15 | P11-T4 | Archived Add_Keyboard_Shortcuts_Command_Palette (PASS) | | 2026-02-15 | P11-T4 | Archived REVIEW_P11-T4_keyboard_shortcuts report | +| 2026-02-15 | P12-T1 | Archived Add_MCP_Client_Identification (PASS) | diff --git a/SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md b/SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/P12-T1_Add_MCP_Client_Identification.md similarity index 100% rename from SPECS/INPROGRESS/P12-T1_Add_MCP_Client_Identification.md rename to SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/P12-T1_Add_MCP_Client_Identification.md diff --git a/SPECS/INPROGRESS/P12-T1_Validation_Report.md b/SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/P12-T1_Validation_Report.md similarity index 100% rename from SPECS/INPROGRESS/P12-T1_Validation_Report.md rename to SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/P12-T1_Validation_Report.md diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index e325f479..d278910c 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,42 +1,18 @@ -# Active Task +# No Active Task -## P12-T1: Add MCP Client Identification - -- **Phase:** Phase 12 — Data Collection Enhancements -- **Priority:** P0 -- **Branch:** feature/P12-T1-mcp-client-identification -- **Started:** 2026-02-15 -- **Dependencies:** P10-T1 ✅ - -## Description - -Detect the calling MCP client from the `initialize` handshake. The `clientInfo` field in the initialize request contains `{name, version}`. Capture this and tag all subsequent metrics with the client identity. Add `client` column to shared metrics SQLite schema. Dashboard: new KPI card "Active Client" showing the connected client name and version. Charts: optional client-based breakdown in tool usage. - -## Outputs/Artifacts - -- Updated `src/mcpbridge_wrapper/__main__.py` - extract `clientInfo` from initialize request -- Updated `src/mcpbridge_wrapper/schemas.py` - add `MCPInitializeParams` model with `clientInfo` -- Updated `src/mcpbridge_wrapper/webui/metrics.py` - `client_name` field in metrics -- Updated `src/mcpbridge_wrapper/webui/shared_metrics.py` - `client` column in SQLite schema -- Updated `src/mcpbridge_wrapper/webui/server.py` - expose client info in metrics summary -- Updated `src/mcpbridge_wrapper/webui/static/index.html` - "Active Client" KPI card -- Updated `src/mcpbridge_wrapper/webui/static/dashboard.js` - render client KPI -- Tests in `tests/unit/test_main.py` and `tests/unit/webui/test_metrics.py` - -## Acceptance Criteria - -- [ ] `initialize` request `clientInfo.name` and `clientInfo.version` are captured -- [ ] Metrics summary includes `client_name` and `client_version` fields -- [ ] Dashboard displays "Active Client" KPI card (e.g. "Cursor 1.2.3") -- [ ] Metrics reset clears client info -- [ ] If `initialize` has no `clientInfo`, fields default to "unknown" -- [ ] SQLite schema migration is backward-compatible (nullable column) -- [ ] Tests cover initialize parsing, missing clientInfo, and metric tagging +The previously selected task has been archived. ## Recently Archived +- 2026-02-15 — P12-T1: Add MCP Client Identification (PASS) - 2026-02-15 — P11-T4: Add Keyboard Shortcuts & Command Palette (PASS) - 2026-02-15 — P11-T3: Add Dashboard Theme Toggle (Dark/Light) (PASS) - 2026-02-15 — P11-T2: Add Session Timeline View (PASS) - 2026-02-15 — BUG-T8: Audit log cross-process visibility (PASS) -- 2026-02-15 — P11-T1: Add Tool Call Detail Inspector (Request/Response Viewer) (PASS) + +## Suggested Next Tasks + +- P12-T2: Add Tool Parameter Frequency Analysis (P3, depends on P12-T1 ✅) +- P12-T3: Add Error Classification & Categorization (P1, depends on P10-T1 ✅) +- FU-BUG-T7-1: Cap `pending_methods` map to guard against unbounded growth (P3) +- FU-P11-T1-1: Refactor `_FakeWebUIConfig` test stub to `MagicMock(spec=WebUIConfig)` (P3) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 15bd6777..875a64bb 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1671,7 +1671,7 @@ Phase 9 Follow-up Backlog **Intent:** Enrich collected telemetry with client identity, parameter patterns, and structured error classification for deeper operational insight. -#### P12-T1: Add MCP Client Identification +#### ✅ P12-T1: Add MCP Client Identification - **Description:** Detect the calling MCP client from the `initialize` handshake. The `clientInfo` field in the initialize request contains `{name, version}`. Capture this and tag all subsequent metrics with the client identity. Add `client` column to shared metrics SQLite schema. Dashboard: new KPI card "Active Client" showing the connected client name and version. Charts: optional client-based breakdown in tool usage. - **Priority:** P0 - **Dependencies:** P10-T1 From 644e6938311dd3b6d7005004103dd79a8d1d1f61 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:31:31 +0300 Subject: [PATCH 5/8] Review P12-T1: MCP client identification --- ...REVIEW_P12-T1_mcp_client_identification.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md diff --git a/SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md b/SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md new file mode 100644 index 00000000..7b1b5dce --- /dev/null +++ b/SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md @@ -0,0 +1,66 @@ +## REVIEW REPORT — P12-T1: MCP Client Identification + +**Scope:** origin/main..HEAD +**Files:** 8 source/test files changed + +--- + +### Summary Verdict +- [ ] Approve +- [x] Approve with comments +- [ ] Request changes +- [ ] Block + +**Overall:** The implementation is correct, well-tested, and follows the existing patterns. Minor observations noted below — none are blockers. + +--- + +### Critical Issues + +None. + +--- + +### Secondary Issues + +**[Low] `MCPInitializeParams` is unused** + +`MCPInitializeParams` was added to `schemas.py` as a planned export but is not used anywhere in the codebase. `MCPParams.clientInfo` now covers the same purpose directly. It's harmless but adds confusion about intent. + +*Suggested fix:* Either remove it (simplest) or add a usage in tests or a helper function to justify its existence. + +**[Low] `initialize_client_info` extracted only on stdin path, not on stdout path** + +If a reflective/proxied initialize arrives on stdout (not stdin), it won't be captured. This is by design since `on_request` only handles stdin, but the comment "extract from initialize handshake" might imply both directions. + +*Suggested fix:* Add a code comment in `on_request` clarifying this only captures client→bridge direction. + +**[Nit] dashboard.js: line length** + +Line adding `clientName + " " + clientVersion` is slightly over the implicit 100-char limit used elsewhere in JS. + +*Suggested fix:* No action required — JS doesn't have an enforced linter rule here. + +--- + +### Architectural Notes + +- `MCPParams.model_config = {"extra": "allow"}` now silently ignores unknown fields in all request params, not just initialize. This is safe but is a broader change than strictly needed. +- The `client_info` table uses a fixed `id=1` sentinel row. This is correct for single-client-per-session semantics but would need rethinking if multiple simultaneous clients were supported. +- `SharedMetricsStore.reset()` now clears client info. This is semantically reasonable but means client identity is lost on reset, requiring a new `initialize` to restore it. + +--- + +### Tests + +- 10 new tests added across 3 files. +- Coverage: 96.04% (above 90% threshold). +- New tests cover: clientInfo extraction, missing clientInfo defaulting to "unknown", set/reset in both MetricsCollector and SharedMetricsStore. +- No gaps identified. + +--- + +### Next Steps + +- FU-P12-T1-1: Remove or document `MCPInitializeParams` (Low priority) +- FU-P12-T1-2: Add comment to `on_request` clarifying stdin-only capture direction From 302adede62523bd9e2945696a69e9b74fbdfeb70 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:31:51 +0300 Subject: [PATCH 6/8] Follow-up P12-T1: add FU-P12-T1-1 and FU-P12-T1-2 to workplan --- SPECS/Workplan.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 875a64bb..5ca51e3e 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1919,6 +1919,32 @@ Phase 9 Follow-up Backlog --- +#### FU-P12-T1-1: Remove or document `MCPInitializeParams` in schemas +- **Description:** `MCPInitializeParams` was added to `schemas.py` during P12-T1 but is not used anywhere in the codebase — `MCPParams.clientInfo` covers the same purpose. Either remove it to reduce confusion, or add a usage (e.g., a helper or test) that justifies its existence as a public export. +- **Priority:** P3 +- **Dependencies:** P12-T1 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - Updated `src/mcpbridge_wrapper/schemas.py` — `MCPInitializeParams` removed or documented with usage +- **Acceptance Criteria:** + - [ ] `MCPInitializeParams` is either removed or has a clear, tested usage + - [ ] `pytest` suite remains green + +--- + +#### FU-P12-T1-2: Add code comment clarifying stdin-only client capture in `on_request` +- **Description:** In `__main__.py`'s `on_request()`, the `initialize` client info capture only fires for requests arriving on stdin (client→bridge). Add a brief comment clarifying this intentional scope to prevent future confusion about whether stdout initialize messages are also handled. +- **Priority:** P3 +- **Dependencies:** P12-T1 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - Updated `src/mcpbridge_wrapper/__main__.py` — comment added near client info capture block +- **Acceptance Criteria:** + - [ ] Comment clearly states stdin-only capture direction + - [ ] No functional changes + +--- + #### FU-P11-T1-1: Refactor `_FakeWebUIConfig` test stub to use `MagicMock(spec=WebUIConfig)` - **Description:** The hand-rolled `_FakeWebUIConfig` class in `tests/unit/test_main.py` must be manually updated every time a new property is added to `WebUIConfig`. Refactor it to use `MagicMock(spec=WebUIConfig)` with only the properties needed by the test wired up, so the spec auto-enforces the real interface and future property additions do not break the test. - **Priority:** P3 From 7973fc74b34b7f9b333f83c6233221bfb97cf062 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:32:19 +0300 Subject: [PATCH 7/8] Archive REVIEW_P12-T1_mcp_client_identification report --- SPECS/ARCHIVE/INDEX.md | 2 ++ .../REVIEW_P12-T1_mcp_client_identification.md | 0 2 files changed, 2 insertions(+) rename SPECS/{INPROGRESS => ARCHIVE/P12-T1_Add_MCP_Client_Identification}/REVIEW_P12-T1_mcp_client_identification.md (100%) diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index f6762647..7f91ca26 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -153,6 +153,7 @@ | [REVIEW_P11-T2_session_timeline.md](P11-T2_Add_Session_Timeline_View/REVIEW_P11-T2_session_timeline.md) | Review report for P11-T2 | | [REVIEW_P11-T3_dashboard_theme_toggle.md](P11-T3_Add_Dashboard_Theme_Toggle/REVIEW_P11-T3_dashboard_theme_toggle.md) | Review report for P11-T3 | | [REVIEW_P11-T4_keyboard_shortcuts.md](P11-T4_Add_Keyboard_Shortcuts_Command_Palette/REVIEW_P11-T4_keyboard_shortcuts.md) | Review report for P11-T4 | +| [REVIEW_P12-T1_mcp_client_identification.md](P12-T1_Add_MCP_Client_Identification/REVIEW_P12-T1_mcp_client_identification.md) | Review report for P12-T1 | ## Archive Log @@ -259,3 +260,4 @@ | 2026-02-15 | P11-T4 | Archived Add_Keyboard_Shortcuts_Command_Palette (PASS) | | 2026-02-15 | P11-T4 | Archived REVIEW_P11-T4_keyboard_shortcuts report | | 2026-02-15 | P12-T1 | Archived Add_MCP_Client_Identification (PASS) | +| 2026-02-15 | P12-T1 | Archived REVIEW_P12-T1_mcp_client_identification report | diff --git a/SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md b/SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/REVIEW_P12-T1_mcp_client_identification.md similarity index 100% rename from SPECS/INPROGRESS/REVIEW_P12-T1_mcp_client_identification.md rename to SPECS/ARCHIVE/P12-T1_Add_MCP_Client_Identification/REVIEW_P12-T1_mcp_client_identification.md From a7d8b2314c68cb6db0a381f4ad2500faf88db00c Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sun, 15 Feb 2026 21:34:18 +0300 Subject: [PATCH 8/8] Fix ruff format: reformat tests/unit/test_main.py --- tests/unit/test_main.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index d4843e7b..b8270658 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -603,13 +603,9 @@ def capture_on_request(bridge, on_request=None): "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui"] ), patch( "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", return_value=mock_metrics - ), patch( - "mcpbridge_wrapper.webui.audit.AuditLogger" - ), patch( + ), patch("mcpbridge_wrapper.webui.audit.AuditLogger"), patch( "mcpbridge_wrapper.webui.server.is_port_available", return_value=True - ), patch( - "mcpbridge_wrapper.webui.server.run_server_in_thread" - ): + ), patch("mcpbridge_wrapper.webui.server.run_server_in_thread"): main() assert len(captured_on_request) == 1 @@ -657,13 +653,9 @@ def capture_on_request(bridge, on_request=None): "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui"] ), patch( "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", return_value=mock_metrics - ), patch( - "mcpbridge_wrapper.webui.audit.AuditLogger" - ), patch( + ), patch("mcpbridge_wrapper.webui.audit.AuditLogger"), patch( "mcpbridge_wrapper.webui.server.is_port_available", return_value=True - ), patch( - "mcpbridge_wrapper.webui.server.run_server_in_thread" - ): + ), patch("mcpbridge_wrapper.webui.server.run_server_in_thread"): main() assert len(captured_on_request) == 1