Skip to content

Commit a4069de

Browse files
RoniCycodeclaude
andcommitted
CM-65100 report per-file MCP config + device fields on CLI session start
Replace the flat top-level mcp_servers payload with a per-file model: global_config_file{path,content} plus each enabled plugin's mcp_config_file + mcp_config_file_path, normalized to {mcpServers}. Add device fields hostname, platform, and logged_in_user to the session-context body; user_id continues to ride the auth token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4f6b76 commit a4069de

12 files changed

Lines changed: 138 additions & 59 deletions

File tree

cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ def load_plugin_json(path: Path) -> Optional[dict]:
2626
return None
2727

2828

29+
def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]:
30+
"""Wrap a global (non-plugin) MCP config into the session-context file shape.
31+
32+
Returns ``{"path": <full path>, "content": <{"mcpServers": ...} JSON>}`` when
33+
there are servers, else ``None``. ``content`` is normalized to the canonical
34+
``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
35+
"""
36+
servers = mcp_servers or {}
37+
if not servers:
38+
return None
39+
return {'path': str(path), 'content': json.dumps({'mcpServers': servers})}
40+
41+
2942
def walk_enabled_plugins(
3043
plugin_entries: dict[str, Any],
3144
is_enabled: Callable[[Any], bool],

cycode/cli/apps/ai_guardrails/ides/base.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]:
167167
"""
168168
return None
169169

170-
def get_session_context(self) -> tuple[dict, dict]:
171-
"""Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
170+
def get_session_context(self) -> tuple[Optional[dict], dict]:
171+
"""Return ``(global_config_file, enabled_plugins)`` for session-context reporting.
172172
173-
Default: empty dicts (no plugin system, no discoverable MCP config).
173+
``global_config_file`` is the IDE's global (non-plugin) MCP config as
174+
``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``,
175+
or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each
176+
enabled plugin key to its metadata (including its own ``mcp_config_file``
177+
content and ``mcp_config_file_path``).
178+
179+
Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
174180
Override to surface MCP/plugin inventory.
175181
"""
176-
return {}, {}
182+
return None, {}

cycode/cli/apps/ai_guardrails/ides/claude_code.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from typing import ClassVar, Optional
88

99
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10-
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
10+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
11+
build_global_config_file,
12+
load_plugin_json,
13+
walk_enabled_plugins,
14+
)
1115
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1216
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1317
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -184,10 +188,12 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
184188
if field in manifest:
185189
entry[field] = manifest[field]
186190

187-
mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
191+
mcp_config_path = plugin_dir / '.mcp.json'
192+
mcp_config = load_plugin_json(mcp_config_path) or {}
188193
servers: dict = mcp_config.get('mcpServers') or {}
189194
if servers:
190195
entry['mcp_server_names'] = list(servers.keys())
196+
entry['mcp_config_file_path'] = str(mcp_config_path)
191197
entry['mcp_config_file'] = json.dumps(mcp_config)
192198
return entry, servers
193199

@@ -355,15 +361,14 @@ def get_user_email(self) -> Optional[str]:
355361
config = load_claude_config()
356362
return _email_from_config(config) if config else None
357363

358-
def get_session_context(self) -> tuple[dict, dict]:
364+
def get_session_context(self) -> tuple[Optional[dict], dict]:
359365
config = load_claude_config()
360-
mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
366+
global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None
361367

362368
settings = load_claude_settings()
363369
if settings:
364-
plugin_mcp, enriched_plugins = resolve_plugins(settings)
365-
mcp_servers.update(plugin_mcp)
370+
_, enriched_plugins = resolve_plugins(settings)
366371
else:
367372
enriched_plugins = {}
368373

369-
return mcp_servers, enriched_plugins
374+
return global_config_file, enriched_plugins

cycode/cli/apps/ai_guardrails/ides/codex.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
import tomli as tomllib
1515

1616
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
17-
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
17+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
18+
build_global_config_file,
19+
load_plugin_json,
20+
walk_enabled_plugins,
21+
)
1822
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1923
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2024
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -129,12 +133,14 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
129133
mcp_ref = manifest.get('mcpServers')
130134
if not mcp_ref:
131135
return entry, {}
132-
mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
136+
mcp_config_path = plugin_dir / mcp_ref
137+
mcp_doc = load_plugin_json(mcp_config_path) or {}
133138
servers = mcp_doc.get('mcpServers', mcp_doc)
134139
if not isinstance(servers, dict):
135140
servers = {}
136141
if servers:
137142
entry['mcp_server_names'] = list(servers.keys())
143+
entry['mcp_config_file_path'] = str(mcp_config_path)
138144
entry['mcp_config_file'] = json.dumps(mcp_doc)
139145
return entry, servers
140146

@@ -298,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
298304
def get_user_email(self) -> Optional[str]:
299305
return _email_from_auth()
300306

301-
def get_session_context(self) -> tuple[dict, dict]:
307+
def get_session_context(self) -> tuple[Optional[dict], dict]:
302308
config = _load_codex_config()
303309
if not config:
304-
return {}, {}
305-
# Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
306-
# servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
307-
mcp_servers: dict = dict(config.get('mcp_servers') or {})
308-
plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
309-
mcp_servers.update(plugin_mcp)
310-
return mcp_servers, enriched_plugins
310+
return None, {}
311+
# Codex stores MCP servers under `[mcp_servers.<name>]`; the global config
312+
# file becomes its own session-context file. Plugins (via
313+
# `[plugins."<plugin>@<marketplace>"]`) carry their own config files.
314+
config_path = _codex_home() / _CONFIG_TOML_NAME
315+
global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
316+
_, enriched_plugins = _resolve_codex_plugins(config)
317+
return global_config_file, enriched_plugins

cycode/cli/apps/ai_guardrails/ides/cursor.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import ClassVar, Optional
77

88
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
9+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file
910
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1011
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1112
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -113,7 +114,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
113114
ide_version=raw_payload.get('cursor_version'),
114115
)
115116

116-
def get_session_context(self) -> tuple[dict, dict]:
117+
def get_session_context(self) -> tuple[Optional[dict], dict]:
117118
config = _load_cursor_mcp_config()
118-
mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
119-
return mcp_servers, {}
119+
if not config:
120+
return None, {}
121+
config_path = Path.home() / '.cursor' / _MCP_CONFIG_FILENAME
122+
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
123+
return global_config_file, {}

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Handle AI guardrails session start: auth, conversation creation, session context."""
22

3+
import getpass
4+
import platform
5+
import socket
36
import sys
47
from typing import TYPE_CHECKING, Annotated, Optional
58

@@ -20,14 +23,25 @@
2023
logger = get_logger('AI Guardrails')
2124

2225

26+
def _get_logged_in_user() -> Optional[str]:
27+
"""Best-effort OS account name (whoami). None if it can't be resolved."""
28+
try:
29+
return getpass.getuser()
30+
except Exception:
31+
return None
32+
33+
2334
def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
2435
"""Report IDE session context to the AI security manager. Never raises."""
2536
try:
26-
mcp_servers, enabled_plugins = ide.get_session_context()
27-
if not mcp_servers and not enabled_plugins:
37+
global_config_file, enabled_plugins = ide.get_session_context()
38+
if not global_config_file and not enabled_plugins:
2839
return
2940
ai_client.report_session_context(
30-
mcp_servers=mcp_servers,
41+
hostname=socket.gethostname(),
42+
platform=platform.system(),
43+
logged_in_user=_get_logged_in_user(),
44+
global_config_file=global_config_file,
3145
enabled_plugins=enabled_plugins,
3246
user_email=user_email,
3347
)

cycode/cyclient/ai_security_manager_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,21 @@ def create_event(
9393

9494
def report_session_context(
9595
self,
96-
mcp_servers: Optional[dict] = None,
96+
hostname: Optional[str] = None,
97+
platform: Optional[str] = None,
98+
logged_in_user: Optional[str] = None,
99+
global_config_file: Optional[dict] = None,
97100
enabled_plugins: Optional[dict] = None,
98101
user_email: Optional[str] = None,
99102
) -> None:
100103
"""Report session context to the backend."""
101104
body: dict = {
102-
'mcp_servers': mcp_servers,
103-
'enabled_plugins': enabled_plugins,
105+
'hostname': hostname,
106+
'platform': platform,
107+
'logged_in_user': logged_in_user,
104108
'user_email': user_email,
109+
'global_config_file': global_config_file,
110+
'enabled_plugins': enabled_plugins,
105111
}
106112

107113
try:

tests/cli/commands/ai_guardrails/ides/test_claude_code.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None:
230230

231231
assert 'mcp_config_file' in entry
232232
assert json.loads(entry['mcp_config_file']) == mcp_content
233+
assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json')
233234
assert servers == mcp_content['mcpServers']
234235

235236

@@ -257,8 +258,8 @@ def test_session_context_no_config() -> None:
257258
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None),
258259
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None),
259260
):
260-
servers, plugins = ClaudeCode().get_session_context()
261-
assert servers == {}
261+
global_config_file, plugins = ClaudeCode().get_session_context()
262+
assert global_config_file is None
262263
assert plugins == {}
263264

264265

tests/cli/commands/ai_guardrails/ides/test_codex.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -325,24 +325,24 @@ def test_session_context_reads_mcp_servers() -> None:
325325
'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config',
326326
return_value={'mcp_servers': mcp},
327327
):
328-
servers, plugins = Codex().get_session_context()
329-
assert servers == mcp
328+
global_config_file, plugins = Codex().get_session_context()
329+
assert global_config_file is not None
330+
assert global_config_file['path'].endswith('config.toml')
331+
assert global_config_file['content'] == json.dumps({'mcpServers': mcp})
330332
assert plugins == {}
331333

332334

333335
def test_session_context_no_config() -> None:
334336
with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None):
335-
servers, plugins = Codex().get_session_context()
336-
assert servers == {}
337+
global_config_file, plugins = Codex().get_session_context()
338+
assert global_config_file is None
337339
assert plugins == {}
338340

339341

340342
def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None:
341343
"""Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself."""
342344
(plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True)
343-
(plugin_dir / '.codex-plugin' / 'plugin.json').write_text(
344-
json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'})
345-
)
345+
(plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'}))
346346
(plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc))
347347

348348

@@ -353,6 +353,7 @@ def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None:
353353
entry, servers = _read_codex_plugin(tmp_path)
354354

355355
assert json.loads(entry['mcp_config_file']) == mcp_content
356+
assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json')
356357
assert servers == mcp_content['mcpServers']
357358

358359

tests/cli/commands/ai_guardrails/ides/test_contract.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,12 @@ def test_build_session_payload_tags_ide(ide: IDE) -> None:
105105

106106

107107
def test_get_session_context_returns_pair(ide: IDE) -> None:
108-
"""Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts."""
109-
mcp_servers, plugins = ide.get_session_context()
110-
assert isinstance(mcp_servers, dict)
108+
"""Session context must be a ``(global_config_file, plugins)`` pair.
109+
110+
``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict.
111+
"""
112+
global_config_file, plugins = ide.get_session_context()
113+
assert global_config_file is None or isinstance(global_config_file, dict)
111114
assert isinstance(plugins, dict)
112115

113116

0 commit comments

Comments
 (0)