55
66import json
77import time
8- from dataclasses import InitVar , dataclass , field
9- from enum import Enum
8+ from dataclasses import dataclass , field
109from pathlib import Path
11- from typing import Optional
10+ from typing import Any , Optional
11+
12+ from marshmallow import EXCLUDE , Schema , fields , post_load
1213
1314from cycode .cli .apps .ai_guardrails .scan .guardrail_config import DEFAULT_TTL_SECONDS
1415from cycode .cli .consts import CYCODE_CONFIGURATION_DIRECTORY
1516from cycode .cli .utils .path_utils import atomic_write_text , quarantine_corrupt_file
17+ from cycode .cyclient .models import McpServerAuthorizationStatus , McpServerStatus , McpServerStatusSchema
1618from cycode .logger import get_logger
1719
1820logger = get_logger ('AI Guardrails' )
1921
2022MCP_SERVER_STATUSES_FILE_NAME = 'ai-guardrails-mcp-servers.json'
2123
2224
23- class McpServerAuthorizationStatus (str , Enum ):
24- AUTHORIZED = 'Authorized'
25- UNREVIEWED = 'Unreviewed'
26- UNAUTHORIZED = 'Unauthorized'
27-
28-
2925# An alias shared by several servers (e.g. configured differently per device) gets the most restrictive status.
3026_RESTRICTIVENESS = {
3127 McpServerAuthorizationStatus .AUTHORIZED : 0 ,
@@ -38,61 +34,57 @@ def get_mcp_server_statuses_cache_path() -> Path:
3834 return Path .home () / CYCODE_CONFIGURATION_DIRECTORY / MCP_SERVER_STATUSES_FILE_NAME
3935
4036
41- def parse_status (raw_status : object ) -> McpServerAuthorizationStatus :
42- """An unknown status reads as Unreviewed: no decision was made on the server."""
43- lowered = str (raw_status or '' ).lower ()
44- for status in McpServerAuthorizationStatus :
45- if status .value .lower () == lowered :
46- return status
47- return McpServerAuthorizationStatus .UNREVIEWED
48-
49-
5037def is_enforced (status : Optional [McpServerAuthorizationStatus ]) -> bool :
5138 """``status`` is None when the platform never saw the server."""
5239 return status == McpServerAuthorizationStatus .UNAUTHORIZED
5340
5441
5542@dataclass
5643class McpServerStatuses :
57- servers : InitVar [ list ]
44+ servers : list [ McpServerStatus ]
5845 fetched_at : float
5946 tenant_id : Optional [str ] = None
6047 ttl_seconds : float = DEFAULT_TTL_SECONDS
6148 _by_alias : dict = field (init = False , repr = False )
6249
63- def __post_init__ (self , servers : list ) -> None :
50+ def __post_init__ (self ) -> None :
6451 # The platform matches aliases case-insensitively, so the CLI does too.
6552 self ._by_alias = {}
66- for server in servers :
67- if not isinstance ( server , dict ) or not server . get ( ' alias' ) :
53+ for server in self . servers :
54+ if not server . alias :
6855 continue
69- key = str (server ['alias' ]).lower ()
70- status = parse_status (server .get ('status' ))
56+ key = server .alias .lower ()
7157 known = self ._by_alias .get (key )
72- if known is None or _RESTRICTIVENESS [status ] > _RESTRICTIVENESS [known ]:
73- self ._by_alias [key ] = status
58+ if known is None or _RESTRICTIVENESS [server . status ] > _RESTRICTIVENESS [known ]:
59+ self ._by_alias [key ] = server . status
7460
7561 def status_of (self , alias : str ) -> Optional [McpServerAuthorizationStatus ]:
7662 return self ._by_alias .get (alias .lower ())
7763
78- def is_expired (self ) -> bool :
79- return time .time () - self .fetched_at > self .ttl_seconds
8064
81- def needs_refresh (self , tenant_id : Optional [str ]) -> bool :
82- return self .is_expired () or self .tenant_id != tenant_id
65+ class McpServerStatusesSchema (Schema ):
66+ class Meta :
67+ unknown = EXCLUDE
68+
69+ servers = fields .List (fields .Nested (McpServerStatusSchema ), required = True )
70+ fetched_at = fields .Float (required = True )
71+ tenant_id = fields .String (allow_none = True , load_default = None )
72+ ttl_seconds = fields .Float (allow_none = True , load_default = None )
73+
74+ @post_load
75+ def build_dto (self , data : dict [str , Any ], ** _ ) -> McpServerStatuses :
76+ data ['ttl_seconds' ] = data ['ttl_seconds' ] or DEFAULT_TTL_SECONDS
77+ return McpServerStatuses (** data )
8378
8479
85- def save_mcp_server_statuses (servers : list , tenant_id : Optional [str ], ttl_seconds : float = DEFAULT_TTL_SECONDS ) -> None :
80+ def save_mcp_server_statuses (
81+ servers : list [McpServerStatus ], tenant_id : Optional [str ], ttl_seconds : float = DEFAULT_TTL_SECONDS
82+ ) -> None :
8683 path = get_mcp_server_statuses_cache_path ()
87- content = {
88- 'fetched_at' : time .time (),
89- 'tenant_id' : tenant_id ,
90- 'ttl_seconds' : ttl_seconds ,
91- 'servers' : servers ,
92- }
84+ statuses = McpServerStatuses (servers = servers , fetched_at = time .time (), tenant_id = tenant_id , ttl_seconds = ttl_seconds )
9385 try :
9486 path .parent .mkdir (parents = True , exist_ok = True )
95- atomic_write_text (str (path ), json .dumps (content ))
87+ atomic_write_text (str (path ), json .dumps (McpServerStatusesSchema (). dump ( statuses ) ))
9688 except Exception as e :
9789 logger .debug ('Failed to save MCP server statuses cache' , exc_info = e )
9890
@@ -104,16 +96,7 @@ def load_mcp_server_statuses() -> Optional[McpServerStatuses]:
10496
10597 try :
10698 with open (path , encoding = 'UTF-8' ) as file :
107- content = json .load (file )
108- servers = content ['servers' ]
109- if not isinstance (servers , list ):
110- raise ValueError ('servers is not a list' )
111- return McpServerStatuses (
112- servers = servers ,
113- fetched_at = float (content ['fetched_at' ]),
114- tenant_id = content .get ('tenant_id' ),
115- ttl_seconds = float (content .get ('ttl_seconds' ) or DEFAULT_TTL_SECONDS ),
116- )
99+ return McpServerStatusesSchema ().load (json .load (file ))
117100 except Exception as e :
118101 logger .warning ('MCP server statuses cache is corrupt and will be moved aside' , exc_info = e )
119102 quarantine_corrupt_file (str (path ))
0 commit comments