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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|