netconverter-cli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ """Minimal MCP Streamable HTTP client for NetConverter's remote read tools.
2
+
3
+ Only the configured NetConverter MCP endpoint is ever contacted (no URL comes
4
+ from a model or a tool result). Requests carry the same Bearer credential as
5
+ the REST client, with one refresh-and-retry on 401.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ import httpx
13
+
14
+ from .api import BearerAuth, as_credential, check_url
15
+ from .clerk import ClerkError
16
+
17
+ DEFAULT_MCP_URL = "https://api.netconverter.ai/mcp/v1/"
18
+ CLIENT_PROTOCOL = "2025-06-18"
19
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
20
+
21
+
22
+ class RemoteError(RuntimeError):
23
+ def __init__(self, code: str, message: str):
24
+ super().__init__(message)
25
+ self.code = code
26
+
27
+
28
+ def _short(value, limit=300) -> str:
29
+ return "".join(ch for ch in str(value or "") if ch.isprintable())[:limit]
30
+
31
+
32
+ class RemoteMCP:
33
+ def __init__(self, url: str, credential, *, transport=None, client_version="0"):
34
+ self.url = check_url(url)
35
+ self.http = httpx.Client(
36
+ timeout=90,
37
+ auth=BearerAuth(as_credential(credential)),
38
+ transport=transport,
39
+ follow_redirects=False,
40
+ trust_env=False,
41
+ )
42
+ self.client_version = client_version
43
+ self.session_id: str | None = None
44
+ self.protocol: str | None = None
45
+ self._initialized = False
46
+ self._next_id = 0
47
+
48
+ def close(self):
49
+ self.http.close()
50
+
51
+ # -- transport ---------------------------------------------------------
52
+
53
+ def _headers(self) -> dict:
54
+ headers = {
55
+ "Accept": "application/json, text/event-stream",
56
+ "Content-Type": "application/json",
57
+ }
58
+ if self.session_id:
59
+ headers["Mcp-Session-Id"] = self.session_id
60
+ if self.protocol:
61
+ headers["MCP-Protocol-Version"] = self.protocol
62
+ return headers
63
+
64
+ def _post(self, payload: dict):
65
+ try:
66
+ response = self.http.post(self.url, json=payload, headers=self._headers())
67
+ except httpx.HTTPError:
68
+ raise RemoteError("connection_failed", "Could not reach the NetConverter MCP server") from None
69
+ except ClerkError as exc:
70
+ raise RemoteError("unauthorized", str(exc)) from None
71
+ if response.status_code == 401:
72
+ raise RemoteError(
73
+ "unauthorized",
74
+ "NetConverter MCP refused the credential; run `netconverter login --clerk` "
75
+ "or store a key with `netconverter login`",
76
+ )
77
+ if response.status_code == 404 and self.session_id:
78
+ raise RemoteError("session_expired", "Remote MCP session expired")
79
+ if response.status_code == 202:
80
+ return None
81
+ if response.is_error:
82
+ raise RemoteError(f"http_{response.status_code}", f"NetConverter MCP returned HTTP {response.status_code}")
83
+ if len(response.content) > MAX_RESPONSE_BYTES:
84
+ raise RemoteError("response_too_large", "Remote MCP response exceeded its limit")
85
+ session = response.headers.get("mcp-session-id")
86
+ if session:
87
+ self.session_id = session
88
+ content_type = response.headers.get("content-type", "")
89
+ if "text/event-stream" in content_type:
90
+ return self._from_sse(response.text, payload.get("id"))
91
+ try:
92
+ return response.json()
93
+ except ValueError:
94
+ raise RemoteError("invalid_response", "Remote MCP returned invalid JSON") from None
95
+
96
+ @staticmethod
97
+ def _from_sse(text: str, request_id):
98
+ data: list[str] = []
99
+ events = []
100
+ for line in text.splitlines() + [""]:
101
+ if line.startswith("data:"):
102
+ data.append(line[5:].lstrip(" "))
103
+ elif line == "" and data:
104
+ events.append("\n".join(data))
105
+ data = []
106
+ for event in events:
107
+ try:
108
+ message = json.loads(event)
109
+ except ValueError:
110
+ continue
111
+ if isinstance(message, dict) and message.get("id") == request_id and (
112
+ "result" in message or "error" in message
113
+ ):
114
+ return message
115
+ raise RemoteError("invalid_response", "Remote MCP stream contained no response")
116
+
117
+ # -- protocol ----------------------------------------------------------
118
+
119
+ def _request(self, method: str, params: dict | None = None) -> dict:
120
+ self._next_id += 1
121
+ payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
122
+ if params is not None:
123
+ payload["params"] = params
124
+ message = self._post(payload)
125
+ if not isinstance(message, dict):
126
+ raise RemoteError("invalid_response", "Remote MCP returned no response")
127
+ if "error" in message:
128
+ error = message["error"] if isinstance(message["error"], dict) else {}
129
+ raise RemoteError(
130
+ str(error.get("code", "error")), _short(error.get("message", "Remote MCP error"))
131
+ )
132
+ result = message.get("result")
133
+ if not isinstance(result, dict):
134
+ raise RemoteError("invalid_response", "Remote MCP returned an invalid result")
135
+ return result
136
+
137
+ def _initialize(self):
138
+ result = self._request(
139
+ "initialize",
140
+ {
141
+ "protocolVersion": CLIENT_PROTOCOL,
142
+ "capabilities": {},
143
+ "clientInfo": {"name": "netconverter-local", "version": self.client_version},
144
+ },
145
+ )
146
+ version = result.get("protocolVersion")
147
+ self.protocol = version if isinstance(version, str) and len(version) <= 20 else CLIENT_PROTOCOL
148
+ self._post({"jsonrpc": "2.0", "method": "notifications/initialized"})
149
+ self._initialized = True
150
+
151
+ def rpc(self, method: str, params: dict | None = None) -> dict:
152
+ if not self._initialized:
153
+ self._initialize()
154
+ try:
155
+ return self._request(method, params)
156
+ except RemoteError as exc:
157
+ if exc.code != "session_expired":
158
+ raise
159
+ self.session_id, self.protocol, self._initialized = None, None, False
160
+ self._initialize()
161
+ return self._request(method, params)
162
+
163
+ def list_tools(self) -> list[dict]:
164
+ tools: list[dict] = []
165
+ cursor = None
166
+ for _ in range(20): # bounded pagination
167
+ result = self.rpc("tools/list", {"cursor": cursor} if cursor else {})
168
+ tools.extend(t for t in result.get("tools", []) if isinstance(t, dict) and isinstance(t.get("name"), str))
169
+ cursor = result.get("nextCursor")
170
+ if not cursor:
171
+ break
172
+ return tools
173
+
174
+ def call_tool(self, name: str, arguments: dict) -> dict:
175
+ return self.rpc("tools/call", {"name": name, "arguments": arguments})
@@ -0,0 +1,179 @@
1
+ """Connection setup; credentials never enter workspace preferences."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+ from .api import Client, DEFAULT_BASE
6
+ from .backend import open_client
7
+ from .credentials import get_secret, save_secret
8
+ from .providers import Provider, ProviderError
9
+ from .workspace import Workspace, WorkspaceError
10
+
11
+
12
+ def account(server):
13
+ return 'server:' + hashlib.sha256(server.rstrip('/').encode()).hexdigest()[:24]
14
+
15
+
16
+ def load_settings(root):
17
+ w = Workspace(root)
18
+ p = w._inside(w.state_dir / 'connection.json')
19
+ if not p.exists():
20
+ return {}
21
+ data = json.loads(p.read_text())
22
+ if set(data) - {'server', 'provider', 'model', 'auth'} or data.get('auth') not in (None, 'key', 'clerk'):
23
+ raise WorkspaceError('Invalid connection settings')
24
+ return data
25
+
26
+
27
+ def store_settings(root, settings):
28
+ w = Workspace(root)
29
+ w._inside(w.state_dir).mkdir(mode=0o700, exist_ok=True)
30
+ w._atomic_replace(w.state_dir / 'connection.json', json.dumps(settings, indent=2).encode())
31
+
32
+
33
+ def connect_model(name, model, key=''):
34
+ """Probe only synthetic intent; failure closes the candidate, not the old model."""
35
+ if name == 'none':
36
+ return None
37
+ if name not in {'openai','anthropic','gemini','ollama'} or not model:
38
+ raise ProviderError('configuration')
39
+ if name != 'ollama':
40
+ key = key.strip() or get_secret(name, prompt=False)
41
+ if not key:
42
+ raise ProviderError('missing_key')
43
+ try:
44
+ provider = Provider(name, (model or '').strip(), key)
45
+ except ValueError:
46
+ raise ProviderError('configuration') from None
47
+ try:
48
+ if provider.select('Find unused objects') != 'unused_objects':
49
+ raise ProviderError('protocol')
50
+ except BaseException:
51
+ provider.close()
52
+ raise
53
+ return provider
54
+
55
+
56
+ def save_model(root, provider):
57
+ settings = load_settings(root)
58
+ settings.update(provider=provider.name if provider else None, model=provider.model if provider else None)
59
+ store_settings(root, settings)
60
+ if provider and provider.name != 'ollama':
61
+ return save_secret(provider.name, provider.key)
62
+ return True
63
+
64
+
65
+ class AccountSignIn:
66
+ """The NetConverter account sign-in as first-run setup sees it (the cli's trust rules)."""
67
+
68
+ def allowed(self, server):
69
+ from . import cli
70
+ return cli.clerk_allowed(server)
71
+
72
+ def session(self):
73
+ from . import cli
74
+ return cli._clerk_session()
75
+
76
+ def login(self):
77
+ from .clerk import ClerkError, login
78
+ try:
79
+ login(notify=print)
80
+ except ClerkError as exc:
81
+ print(str(exc))
82
+ return None
83
+ return self.session()
84
+
85
+
86
+ def connect_credential(server, signin, ask, secret):
87
+ """A stored key, the existing sign-in, or the user's choice of signing in or pasting a key."""
88
+ saved = get_secret(account(server), prompt=False)
89
+ if not saved and signin.allowed(server):
90
+ session = signin.session()
91
+ if session is not None:
92
+ print('Using your NetConverter sign-in.')
93
+ return session
94
+ while True:
95
+ choice = ask('Connect with 1 = your NetConverter account (browser sign-in), 2 = an API key [1]: ').strip() or '1'
96
+ if choice in ('1', '2'):
97
+ break
98
+ print('Enter 1 or 2.')
99
+ if choice == '1':
100
+ session = signin.login()
101
+ if session is None:
102
+ raise ValueError('Sign-in did not complete; run `netconverter login --clerk` and try again')
103
+ return session
104
+ key = secret('NetConverter API key (hidden; paste here, then Enter; blank uses saved key): ').strip()
105
+ key = key or saved
106
+ if not key:
107
+ raise ValueError('A server API key is required')
108
+ return key
109
+
110
+
111
+ def configure(root, defaults=None, *, ask=None, secret=None, signin=None):
112
+ import getpass
113
+ from rich.console import Console
114
+ from .branding import banner
115
+ ask = ask or input
116
+ secret = secret or getpass.getpass
117
+ signin = signin or AccountSignIn()
118
+ settings = {**load_settings(root), **(defaults or {})}
119
+ Console().print(banner(Path(root).resolve()))
120
+ print('\n1 / 2 Connect to NetConverter')
121
+ print('Press Enter for the production URL. A key, if you use one, goes only in the hidden prompt.')
122
+ while True:
123
+ server = ask(f"Server URL [{settings.get('server') or DEFAULT_BASE}]: ").strip() or settings.get('server') or DEFAULT_BASE
124
+ if server.startswith(('nck_', 'sk-')):
125
+ print('That looks like an API key, not a URL. It was entered in a visible field; replace it if exposed. Press Enter below for the server address.')
126
+ continue
127
+ try:
128
+ Client('', server).close()
129
+ break
130
+ except ValueError:
131
+ print('Enter an HTTPS server URL, or press Enter to accept the displayed address.')
132
+ credential = connect_credential(server, signin, ask, secret)
133
+ client, kind = open_client(credential, server)
134
+ print('Connected: ' + ('full API — every supported vendor' if kind == 'external'
135
+ else 'pilot key — ASA → Palo Alto'))
136
+ if isinstance(credential, str):
137
+ stored = save_secret(account(server), credential)
138
+ print('Server key saved in OS credential store.' if stored else 'Credential store unavailable; server key is in memory for this session only.')
139
+ else:
140
+ print('Signed in with your NetConverter account; no key is stored.')
141
+ provider = None
142
+ try:
143
+ print('\n2 / 2 Connect a model (optional)')
144
+ print('A model chooses operations only. Configurations and detailed results stay out of model requests.')
145
+ name_default = settings.get('provider') or 'none'
146
+ while True:
147
+ name = ask(f'Model provider: none / ollama / anthropic / openai / gemini [{name_default}]: ').strip().lower() or name_default
148
+ if name not in {'none','ollama','anthropic','openai','gemini'}:
149
+ print('Choose one of the listed providers.')
150
+ continue
151
+ if name == 'none':
152
+ print('Guided commands selected. Use /model inside the terminal to connect later.')
153
+ break
154
+ model_default = settings.get('model') if name == settings.get('provider') else ''
155
+ model = ask(f'Exact API model ID [{model_default or ""}]: ').strip() or model_default
156
+ key = '' if name == 'ollama' else secret(f'{name} API key (hidden; blank uses saved key): ').strip()
157
+ print('Testing a synthetic tool call…')
158
+ try:
159
+ provider = connect_model(name, model, key)
160
+ except Exception as exc:
161
+ print(str(exc) if isinstance(exc, ProviderError) else ProviderError.MESSAGES['protocol'])
162
+ choice = ask('Retry model setup or continue without a model? [retry / guided]: ').strip().lower()
163
+ if choice == 'guided':
164
+ print('Guided commands selected. Use /model to retry later.')
165
+ break
166
+ name_default = name
167
+ continue
168
+ print(f'Model connected: {provider.name} / {provider.model}')
169
+ break
170
+ store_settings(root, {'server':server, 'provider':None, 'model':None})
171
+ stored = save_model(root, provider)
172
+ if not stored:
173
+ print('Model key is in memory only; enter it again after restarting.')
174
+ return client, provider
175
+ except BaseException:
176
+ client.close()
177
+ if provider:
178
+ provider.close()
179
+ raise