-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathsetup.py
More file actions
551 lines (478 loc) · 24.2 KB
/
Copy pathsetup.py
File metadata and controls
551 lines (478 loc) · 24.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
# --------------------------------------------------------------------------
# Copyright Commvault Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# --------------------------------------------------------------------------
import os
import re
import secrets
import sys
import time
from datetime import datetime
from getpass import getpass
from urllib.parse import urljoin
try:
import msvcrt # Windows
_MASKED_INPUT_BACKEND = "msvcrt"
termios = None
tty = None
except ImportError:
msvcrt = None
import termios # POSIX
import tty
_MASKED_INPUT_BACKEND = "termios"
import keyring
import requests
from rich.console import Console
from rich.prompt import Prompt
from pyfiglet import Figlet
from src.utils import get_env_var, get_keyring_service_name
console = Console()
ENV_FILE = '.env'
# Allowed characters for MCP_INSTANCE_ID. Conservative on purpose - this value
# is embedded into the OS keyring service identifier, so we forbid whitespace,
# path separators, and any character that could be awkward in a credential
# manager UI or logs.
INSTANCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,32}$")
def print_title():
f = Figlet(font='slant')
ascii_art = f.renderText('Commvault \nMCP Server')
console.print(f"[bold][red]{ascii_art}[/red][/bold]")
def load_env():
env_vars = {}
if os.path.exists(ENV_FILE):
with open(ENV_FILE, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env_vars[k.strip()] = v.strip()
return env_vars
def save_env(env_vars):
with open(ENV_FILE, 'w') as f:
for k, v in env_vars.items():
f.write(f"{k}={v}\n")
def getpass_masked(prompt: str = "Password: ") -> str:
"""
Read a secret from the terminal, echoing '*' for each character typed.
This lets the user see how long their pasted token is (useful for spotting
truncated copy/paste) while still keeping the value visually masked. Falls
back to plain getpass() when stdin isn't a TTY (piped input, CI, etc.).
Supports Backspace to erase, Enter to submit, and Ctrl+C to cancel.
"""
if not sys.stdin.isatty():
return getpass(prompt)
sys.stdout.write(prompt)
sys.stdout.flush()
chars: list[str] = []
if _MASKED_INPUT_BACKEND == "msvcrt":
while True:
ch = msvcrt.getwch()
if ch in ("\r", "\n"):
sys.stdout.write("\n")
sys.stdout.flush()
break
if ch == "\x03": # Ctrl+C
sys.stdout.write("\n")
raise KeyboardInterrupt
if ch in ("\x00", "\xe0"):
# Function/arrow key prefix on Windows; consume and ignore the next code.
msvcrt.getwch()
continue
if ch in ("\x08", "\x7f"): # Backspace
if chars:
chars.pop()
sys.stdout.write("\b \b")
sys.stdout.flush()
continue
chars.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
else:
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("\r", "\n"):
sys.stdout.write("\r\n")
sys.stdout.flush()
break
if ch == "\x03": # Ctrl+C
sys.stdout.write("\r\n")
raise KeyboardInterrupt
if ch in ("\x08", "\x7f"): # Backspace / DEL
if chars:
chars.pop()
sys.stdout.write("\b \b")
sys.stdout.flush()
continue
chars.append(ch)
sys.stdout.write("*")
sys.stdout.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return "".join(chars)
def validate_https_url(url):
if not url:
return False, "URL cannot be empty."
url_lower = url.lower().strip()
if url_lower.startswith('http://'):
return False, "HTTP URLs are not allowed for security reasons. Please use HTTPS."
if not url_lower.startswith('https://'):
return False, "URL must start with 'https://' for secure communication."
return True, None
def is_keyring_secure():
"""
Check if the current keyring backend is secure.
Returns (is_secure: bool, backend_name: str, backend_type: str)
"""
try:
current_keyring = keyring.get_keyring()
backend_name = current_keyring.name if hasattr(current_keyring, 'name') else 'Unknown'
backend_type = type(current_keyring).__name__
backend_module = type(current_keyring).__module__
full_backend_path = f"{backend_module}.{backend_type}"
# Whitelist of secure keyring backends
secure_backends = frozenset([
# Windows secure backends
'keyring.backends.Windows.WinVaultKeyring',
'keyring.backends.Windows.WinCredentialStore',
'keyring.backends.windows.WinVaultKeyring',
'keyring.backends.windows.WinCredentialStore',
# macOS secure backends
'keyring.backends.macOS.Keyring',
'keyring.backends.macos.Keyring',
'keyring.backends.OS_X.Keyring',
'keyring.backends.osx.Keyring',
# Linux secure backends
'keyring.backends.SecretService.Keyring',
'keyring.backends.secretstorage.Keyring',
'keyring.backends.kwallet.Keyring',
'keyring.backends.kwallet.DBusKeyring',
])
is_secure = full_backend_path in secure_backends
return is_secure, backend_name, full_backend_path
except Exception as e:
return False, 'Unknown', f'Error checking backend: {str(e)}'
def validate_commvault_tokens(access_token, refresh_token, server_url, is_metallic=False):
"""
Validate Commvault access and refresh tokens by making a test API call.
"""
if not access_token or not refresh_token:
return False, "Access token and refresh token are required."
if not server_url:
return False, "Commvault server URL is required for token validation."
try:
if is_metallic:
api_base_url = server_url.rstrip('/') + '/'
else:
api_base_url = urljoin(server_url.rstrip('/') + '/', 'commandcenter/api/')
test_endpoint = urljoin(api_base_url, 'v2/whoami')
ssl_verify = get_env_var("SSL_VERIFY", default="true").lower() == "true"
console.print("[dim]Validating Commvault tokens...[/dim]")
bearer_headers = {
'Accept': 'application/json',
'Authorization': f'Bearer {access_token}',
'User-Agent': 'commvault-mcp-server/0.1.0'
}
response = requests.get(test_endpoint, headers=bearer_headers, timeout=10, verify=ssl_verify)
if response.status_code == 200:
return True, None
if response.status_code == 401:
authtoken_headers = {
'Accept': 'application/json',
'Authtoken': access_token,
'User-Agent': 'commvault-mcp-server/0.1.0'
}
response = requests.get(test_endpoint, headers=authtoken_headers, timeout=10, verify=ssl_verify)
if response.status_code == 200:
return True, None
elif response.status_code == 401:
return False, "Invalid access token. The token may be expired or incorrect. Please generate a new token."
else:
return False, f"Token validation failed with HTTP {response.status_code}. Please check your tokens and server URL."
else:
return False, f"Token validation failed with HTTP {response.status_code}. Please check your tokens and server URL."
except requests.exceptions.SSLError as e:
return False, f"SSL verification failed: {str(e)}. Please check your server certificate."
except requests.exceptions.ConnectionError as e:
return False, f"Connection failed: {str(e)}. Please check your server URL and network connectivity."
except requests.exceptions.Timeout:
return False, "Connection timeout. Please check your server URL and network connectivity."
except Exception as e:
return False, f"Token validation error: {str(e)}"
def prompt_instance_id(env_vars):
"""
Prompt for an optional MCP_INSTANCE_ID used to namespace OS keyring entries.
The OS keyring is per-user, so running multiple MCP server installs on the
same host under the same OS user would otherwise collide on shared keys
(server_secret, server_secret_expiry, access_token, refresh_token). Each
rerun of setup.py would silently overwrite another instance's credentials.
Setting a unique instance ID per install (e.g. "prod", "dr", "site-a")
gives each install its own isolated keyring slots. Existing single-instance
installs can keep the default and their keyring entries are unchanged.
"""
console.print("\n[bold underline]MCP Server Instance[/bold underline]")
console.print(
"Set a unique [bold]instance ID[/bold] only if you run multiple MCP server installs "
"on the same host under the same OS user. Each instance must use a different ID "
"so their secrets in the OS keyring do not overwrite each other."
)
console.print("Press Enter to keep the default (single-instance) configuration.\n")
current_val = env_vars.get('MCP_INSTANCE_ID', '')
default_display = current_val if current_val else 'default'
while True:
val = Prompt.ask(
"MCP_INSTANCE_ID (letters, digits, '.', '-', '_'; max 32 chars)",
default=default_display
)
val = (val or '').strip()
if not val or val.lower() == 'default':
env_vars.pop('MCP_INSTANCE_ID', None)
console.print("[green]Using default (single-instance) keyring namespace.[/green]")
return env_vars
if INSTANCE_ID_PATTERN.match(val):
env_vars['MCP_INSTANCE_ID'] = val
console.print(
f"[green]Instance ID set to '{val}'. Keyring entries will be namespaced as "
f"'commvault-mcp-server:{val}'.[/green]"
)
return env_vars
console.print(
"[red]Invalid instance ID. Use only letters, digits, '.', '-', '_' (max 32 chars).[/red]"
)
def prompt_update_env(env_vars):
keys = ['CC_SERVER_URL', 'MCP_TRANSPORT_MODE', 'MCP_HOST', 'MCP_PORT', 'MCP_PATH']
transport_modes = ['streamable-http', 'stdio', 'sse']
console.print("\n[bold underline]Environment Variables[/bold underline]")
console.print("Press Enter to keep the current value (shown in brackets).\n")
current_metallic = env_vars.get('IS_METALLIC', 'false').lower()
is_metallic = Prompt.ask(
"Is this a Metallic setup? (y/n)",
default='y' if current_metallic == 'true' else 'n'
)
if is_metallic.lower() in ['y', 'yes', 'true']:
env_vars['IS_METALLIC'] = 'true'
env_vars['CC_SERVER_URL'] = 'https://api.metallic.io'
console.print(f"[green]Using Metallic gateway: https://api.metallic.io[/green]")
else:
env_vars['IS_METALLIC'] = 'false'
for key in keys:
current_val = env_vars.get(key, '')
if key == 'CC_SERVER_URL':
if env_vars.get('IS_METALLIC') == 'true':
continue
while True:
val = Prompt.ask(key, default=current_val if current_val else '')
if not val:
console.print("[red]CC_SERVER_URL is required. Please enter a valid HTTPS URL.[/red]")
continue
is_valid, error_msg = validate_https_url(val)
if is_valid:
env_vars[key] = val
break
else:
console.print(f"[red]{error_msg}[/red]")
elif key == 'MCP_TRANSPORT_MODE':
console.print(f"{key} [dim](Current: {current_val if current_val else 'None'})[/dim]")
for i, mode in enumerate(transport_modes, 1):
console.print(f" {i}. {mode}")
while True:
choice = Prompt.ask("Select transport mode [1-3]", default=str(
transport_modes.index(current_val) + 1) if current_val in transport_modes else "1")
if choice in ['1', '2', '3']:
val = transport_modes[int(choice) - 1]
env_vars[key] = val
break
else:
console.print("[red]Invalid choice. Please enter 1, 2, or 3.[/red]")
if val == 'stdio':
env_vars[key] = val
break # other variables are not needed for stdio mode
elif key == 'MCP_PATH':
val = Prompt.ask(key, default=current_val if current_val else '/mcp')
else:
val = Prompt.ask(key, default=current_val)
env_vars[key] = val
# Ask about OAuth configuration only for non-stdio modes
if env_vars.get('MCP_TRANSPORT_MODE') != 'stdio':
console.print("\n[bold underline]Authentication Configuration[/bold underline]")
current_oauth = env_vars.get('USE_OAUTH', 'false').lower()
use_oauth = Prompt.ask("Use OAuth for authentication? (y/n)",
default='y' if current_oauth == 'true' else 'n')
if use_oauth.lower() in ['y', 'yes', 'true']:
env_vars['USE_OAUTH'] = 'true'
console.print("\n[bold]OAuth Configuration[/bold]")
# First ask for discovery endpoint
discovery_endpoint = Prompt.ask("OAuth Discovery Endpoint URL",
default=env_vars.get('OAUTH_DISCOVERY_ENDPOINT', ''))
env_vars['OAUTH_DISCOVERY_ENDPOINT'] = discovery_endpoint
# If discovery endpoint is provided, fetch and set the other endpoints
if discovery_endpoint:
try:
console.print("[dim]Fetching OAuth configuration from discovery endpoint...[/dim]")
response = requests.get(discovery_endpoint)
if response.status_code == 200:
discovery_data = response.json()
env_vars['OAUTH_AUTHORIZATION_ENDPOINT'] = discovery_data.get('authorization_endpoint', '')
env_vars['OAUTH_TOKEN_ENDPOINT'] = discovery_data.get('token_endpoint', '')
env_vars['OAUTH_JWKS_URI'] = discovery_data.get('jwks_uri', '')
console.print("[green]Successfully retrieved OAuth endpoints from discovery URL.[/green]")
else:
console.print(f"[red]Failed to fetch from discovery endpoint (HTTP {response.status_code}). Setup aborted.[/red]")
exit(1)
except Exception as e:
console.print(f"[red]Error fetching from discovery endpoint: {str(e)} Setup aborted.[/red]")
exit(1)
# Add the remaining OAuth configuration that can't be obtained from discovery
oauth_keys = [
('OAUTH_CLIENT_ID', 'OAuth Client ID'),
('OAUTH_CLIENT_SECRET', 'OAuth Client Secret'),
('OAUTH_REQUIRED_SCOPES', 'OAuth Required Scopes (comma-separated)'),
('OAUTH_BASE_URL', 'OAuth Base URL')
]
for key, description in oauth_keys:
current_val = env_vars.get(key, '')
if key == 'OAUTH_CLIENT_SECRET':
masked = '*' * len(current_val) if current_val else ''
val = getpass_masked(f"{description} [{masked}]: ")
if val:
console.print(f"[green]{description} updated.[/green]")
else:
console.print(f"[yellow]{description} unchanged.[/yellow]")
if not val:
val = current_val
else:
val = Prompt.ask(f"{description}", default=current_val)
env_vars[key] = val
else:
env_vars['USE_OAUTH'] = 'false'
# Remove OAuth-related vars if user chooses not to use OAuth
oauth_keys_to_remove = [
'OAUTH_AUTHORIZATION_ENDPOINT', 'OAUTH_TOKEN_ENDPOINT',
'OAUTH_CLIENT_ID', 'OAUTH_JWKS_URI', 'OAUTH_REQUIRED_SCOPES',
'OAUTH_BASE_URL', 'OAUTH_CLIENT_SECRET', 'OAUTH_DISCOVERY_ENDPOINT'
]
for key in oauth_keys_to_remove:
env_vars.pop(key, None)
return env_vars
def prompt_and_save_keyring(service_name, env_vars):
# Only ask for keyring secrets if NOT using OAuth
if env_vars.get('USE_OAUTH', 'false').lower() != 'true':
# Check keyring backend security before proceeding
is_secure, backend_name, backend_path = is_keyring_secure()
if not is_secure:
console.print(f"\n[bold red]SECURITY ERROR: Unsupported Keyring Backend Detected[/bold red]")
console.print(f"[yellow]Current backend: {backend_path}[/yellow]")
console.print("[red]Only secure, OS-native keyring backends are allowed for security reasons.[/red]\n")
console.print("[bold yellow]For detailed information about supported backends and configuration instructions,[/bold yellow]")
console.print("[bold yellow]please refer to the README.md file (Prerequisites > Secure Keyring Backend section).[/bold yellow]\n")
console.print("[red]Setup aborted. Please configure a secure keyring backend before proceeding.[/red]")
exit(1)
console.print(f"\n[bold underline]Secure Tokens (stored in OS keyring)[/bold underline]")
console.print("[bold yellow]Warning: Ensure you're entering sensitive tokens in a secure terminal environment.[/bold yellow]\n")
# Auto-generate server_secret
console.print("[bold]Server Secret (Auto-generated)[/bold]")
server_secret = secrets.token_urlsafe(32) # 32 bytes = 43 characters URL-safe
# Calculate expiry: 30 days from now
expiry_timestamp = time.time() + (30 * 24 * 60 * 60) # 30 days in seconds
expiry_date = datetime.fromtimestamp(expiry_timestamp)
# Store server_secret and its expiry
keyring.set_password(service_name, 'server_secret', server_secret)
keyring.set_password(service_name, 'server_secret_expiry', str(expiry_timestamp))
console.print("[green]Server secret generated and stored securely.[/green]")
console.print("\n[bold yellow]IMPORTANT: Copy this server secret for your LLM configuration:[/bold yellow]")
console.print(f"[bold cyan]{server_secret}[/bold cyan]")
console.print("[dim]This secret must be included in the Authorization header when connecting to the MCP server.[/dim]")
console.print(f"[dim]This secret will expire on {expiry_date.strftime('%Y-%m-%d %H:%M:%S')}. You will need to regenerate it after expiration.[/dim]\n")
# Prompt for access_token and refresh_token with validation
console.print("[bold]Commvault API Tokens[/bold]")
console.print("Leave blank to keep the existing token.\n")
server_url = env_vars.get('CC_SERVER_URL', '')
# Handle access_token
while True:
current_access = keyring.get_password(service_name, 'access_token')
display_val = "<hidden>" if current_access else "none"
access_token = getpass_masked(f"Enter access_token [{display_val}]: ").strip()
if not access_token:
# User wants to keep existing token
if current_access:
console.print("[yellow]Access token unchanged.[/yellow]")
access_token = current_access
break
else:
console.print("[red]Access token is required. Please enter a valid token.[/red]")
continue
# Handle refresh_token
current_refresh = keyring.get_password(service_name, 'refresh_token')
display_val = "<hidden>" if current_refresh else "none"
refresh_token = getpass_masked(f"Enter refresh_token [{display_val}]: ").strip()
if not refresh_token:
# User wants to keep existing refresh token
if current_refresh:
refresh_token = current_refresh
else:
console.print("[red]Refresh token is required when updating access token.[/red]")
continue
# Validate tokens if server URL is available
if server_url:
is_metallic = env_vars.get('IS_METALLIC', 'false').lower() == 'true'
is_valid, error_msg = validate_commvault_tokens(access_token, refresh_token, server_url, is_metallic)
if is_valid:
# Store validated tokens
keyring.set_password(service_name, 'access_token', access_token)
keyring.set_password(service_name, 'refresh_token', refresh_token)
console.print("[green]Tokens validated and stored successfully.[/green]")
break
else:
console.print(f"[red]✗ {error_msg}[/red]")
retry = Prompt.ask("Would you like to try again? (y/n)", default='y')
if retry.lower() not in ['y', 'yes']:
console.print("[yellow]Skipping token update. Existing tokens (if any) will be used.[/yellow]")
break
else:
# No server URL available, store without validation
console.print("[yellow]⚠ Warning: Server URL not configured. Storing tokens without validation.[/yellow]")
keyring.set_password(service_name, 'access_token', access_token)
keyring.set_password(service_name, 'refresh_token', refresh_token)
console.print("[green]✓ Tokens stored (not validated).[/green]")
break
else:
console.print(f"\n[bold green]OAuth authentication enabled - skipping keyring token setup.[/bold green]")
console.print("[dim]OAuth will handle authentication using the configured endpoints and client credentials.[/dim]")
def main():
console.clear()
print_title()
env_vars = load_env()
env_vars = prompt_instance_id(env_vars)
env_vars = prompt_update_env(env_vars)
save_env(env_vars)
console.print(f"\n[green]Updated {ENV_FILE} file.[/green]")
# Sync the freshly chosen instance id into the live process environment so
# get_keyring_service_name() resolves to the correct namespaced service
# name when we write secrets below. dotenv loaded at import time will not
# pick up edits we just made to the .env file.
if 'MCP_INSTANCE_ID' in env_vars:
os.environ['MCP_INSTANCE_ID'] = env_vars['MCP_INSTANCE_ID']
else:
os.environ.pop('MCP_INSTANCE_ID', None)
service_name = get_keyring_service_name()
prompt_and_save_keyring(service_name, env_vars)
console.print(
f"\n[dim]Credentials stored under keyring service '{service_name}'.[/dim]"
)
console.print("\n[bold green]Setup complete! You can now run the MCP server (uv run -m src.server)[/bold green]")
if __name__ == '__main__':
main()