tescmd 0.2.0__py3-none-any.whl → 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.
Files changed (65) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +15 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
tescmd/mcp/server.py ADDED
@@ -0,0 +1,648 @@
1
+ """FastMCP server factory exposing tescmd commands as MCP tools.
2
+
3
+ Uses Click's CliRunner to invoke tescmd commands, guaranteeing behavioral
4
+ parity with the CLI (caching, wake, auth, error handling all work).
5
+
6
+ Each tool calls ``runner.invoke(cli, ["--format", "json", "--wake", *args])``
7
+ and returns the JSON output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _make_cli_runner() -> Any:
25
+ """Create a CliRunner with stderr separation.
26
+
27
+ Click 8.2 removed the ``mix_stderr`` parameter (stderr is always
28
+ separate). Click 8.1 defaults to ``mix_stderr=True``, so we pass
29
+ ``False`` when supported. Routed through ``Any`` to avoid a
30
+ version-dependent ``type: ignore`` that fails strict mypy on one
31
+ version or the other.
32
+ """
33
+ from click.testing import CliRunner as _Runner
34
+
35
+ _ctor: Any = _Runner
36
+ try:
37
+ return _ctor(mix_stderr=False)
38
+ except TypeError:
39
+ return _Runner()
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class _CliToolDef:
44
+ """Definition of a CLI-backed MCP tool."""
45
+
46
+ args: list[str]
47
+ description: str
48
+ is_write: bool
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class _CustomToolDef:
53
+ """Definition of a custom callable MCP tool."""
54
+
55
+ handler: Callable[[dict[str, Any]], dict[str, Any]]
56
+ description: str
57
+ input_schema: dict[str, Any]
58
+ is_write: bool
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Tool definitions — command name → (args_template, description, is_write)
63
+ # ---------------------------------------------------------------------------
64
+
65
+ _READ_TOOLS: dict[str, tuple[list[str], str]] = {
66
+ "vehicle_list": (["vehicle", "list"], "List all vehicles on the account"),
67
+ "vehicle_info": (["vehicle", "info"], "Get vehicle info summary"),
68
+ "vehicle_data": (["vehicle", "data"], "Get full vehicle data"),
69
+ "vehicle_location": (["vehicle", "location"], "Get vehicle location"),
70
+ "vehicle_alerts": (["vehicle", "alerts"], "Get vehicle alerts"),
71
+ "vehicle_nearby_chargers": (
72
+ ["vehicle", "nearby-chargers"],
73
+ "Find nearby Superchargers and destination chargers",
74
+ ),
75
+ "vehicle_release_notes": (["vehicle", "release-notes"], "Get software release notes"),
76
+ "vehicle_service": (["vehicle", "service"], "Get service status"),
77
+ "vehicle_drivers": (["vehicle", "drivers"], "List authorized drivers"),
78
+ "vehicle_specs": (["vehicle", "specs"], "Get vehicle specifications"),
79
+ "vehicle_warranty": (["vehicle", "warranty"], "Get warranty information"),
80
+ "vehicle_fleet_status": (["vehicle", "fleet-status"], "Get fleet telemetry status"),
81
+ "vehicle_subscriptions": (["vehicle", "subscriptions"], "List subscriptions"),
82
+ "charge_status": (["charge", "status"], "Get charge status"),
83
+ "climate_status": (["climate", "status"], "Get climate status"),
84
+ "security_status": (["security", "status"], "Get security/lock status"),
85
+ "software_status": (["software", "status"], "Get software update status"),
86
+ "energy_list": (["energy", "list"], "List energy products (Powerwall)"),
87
+ "energy_status": (["energy", "status"], "Get energy site status"),
88
+ "energy_live": (["energy", "live"], "Get live power flow data"),
89
+ "energy_history": (["energy", "history"], "Get energy history"),
90
+ "billing_history": (["billing", "history"], "Get Supercharger billing history"),
91
+ "billing_sessions": (["billing", "sessions"], "Get Supercharger charging sessions"),
92
+ "user_me": (["user", "me"], "Get account info"),
93
+ "user_region": (["user", "region"], "Get account region"),
94
+ "user_orders": (["user", "orders"], "Get vehicle orders"),
95
+ "user_features": (["user", "features"], "Get feature flags"),
96
+ "cache_status": (["cache", "status"], "Get cache status"),
97
+ "auth_status": (["auth", "status"], "Get auth/token status"),
98
+ }
99
+
100
+ _WRITE_TOOLS: dict[str, tuple[list[str], str]] = {
101
+ "charge_start": (["charge", "start"], "Start charging"),
102
+ "charge_stop": (["charge", "stop"], "Stop charging"),
103
+ "charge_limit": (["charge", "limit"], "Set charge limit (percentage)"),
104
+ "charge_limit_max": (["charge", "limit-max"], "Set charge limit to maximum"),
105
+ "charge_limit_std": (["charge", "limit-std"], "Set charge limit to standard"),
106
+ "charge_amps": (["charge", "amps"], "Set charge amperage"),
107
+ "charge_port_open": (["charge", "port-open"], "Open charge port"),
108
+ "charge_port_close": (["charge", "port-close"], "Close charge port"),
109
+ "climate_on": (["climate", "on"], "Turn on climate control"),
110
+ "climate_off": (["climate", "off"], "Turn off climate control"),
111
+ "climate_set": (["climate", "set"], "Set climate temperature"),
112
+ "climate_precondition": (["climate", "precondition"], "Precondition cabin"),
113
+ "climate_seat": (["climate", "seat"], "Set seat heater level"),
114
+ "climate_wheel_heater": (["climate", "wheel-heater"], "Toggle steering wheel heater"),
115
+ "climate_bioweapon": (["climate", "bioweapon"], "Toggle bioweapon defense mode"),
116
+ "security_lock": (["security", "lock"], "Lock the vehicle"),
117
+ "security_unlock": (["security", "unlock"], "Unlock the vehicle"),
118
+ "security_sentry": (["security", "sentry"], "Toggle sentry mode"),
119
+ "security_flash": (["security", "flash"], "Flash the lights"),
120
+ "security_honk": (["security", "honk"], "Honk the horn"),
121
+ "security_remote_start": (["security", "remote-start"], "Enable remote start"),
122
+ "trunk_open": (["trunk", "open"], "Open the trunk"),
123
+ "trunk_close": (["trunk", "close"], "Close the trunk"),
124
+ "trunk_frunk": (["trunk", "frunk"], "Open the frunk"),
125
+ "trunk_window": (["trunk", "window"], "Vent or close windows"),
126
+ "media_play_pause": (["media", "play-pause"], "Toggle media play/pause"),
127
+ "media_next_track": (["media", "next-track"], "Skip to next track"),
128
+ "media_prev_track": (["media", "prev-track"], "Go to previous track"),
129
+ "media_adjust_volume": (["media", "adjust-volume"], "Set media volume level"),
130
+ "nav_send": (["nav", "send"], "Send a destination to the vehicle"),
131
+ "nav_gps": (["nav", "gps"], "Navigate to GPS coordinates (lat,lon)"),
132
+ "nav_supercharger": (["nav", "supercharger"], "Navigate to nearest Supercharger"),
133
+ "nav_waypoints": (["nav", "waypoints"], "Send multi-stop waypoints via Google Place IDs"),
134
+ "nav_homelink": (["nav", "homelink"], "Trigger HomeLink (garage door)"),
135
+ "software_schedule": (["software", "schedule"], "Schedule software update"),
136
+ "software_cancel": (["software", "cancel"], "Cancel pending software update"),
137
+ "vehicle_wake": (["vehicle", "wake"], "Wake the vehicle"),
138
+ "vehicle_rename": (["vehicle", "rename"], "Rename the vehicle"),
139
+ "cache_clear": (["cache", "clear"], "Clear response cache"),
140
+ }
141
+
142
+ # Commands excluded from MCP (long-running, interactive, or infrastructure)
143
+ _EXCLUDED = {
144
+ "vehicle telemetry stream",
145
+ "openclaw bridge",
146
+ "serve",
147
+ "auth login",
148
+ "auth logout",
149
+ "auth register",
150
+ "setup",
151
+ "mcp serve",
152
+ "key generate",
153
+ "key deploy",
154
+ "key enroll",
155
+ "key unenroll",
156
+ }
157
+
158
+
159
+ class MCPServer:
160
+ """MCP server that wraps tescmd CLI commands as tools."""
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ client_id: str,
166
+ client_secret: str,
167
+ ) -> None:
168
+ self._client_id = client_id
169
+ self._client_secret = client_secret
170
+ self._tools: dict[str, _CliToolDef] = {}
171
+ self._custom_tools: dict[str, _CustomToolDef] = {}
172
+ for name, (args, desc) in _READ_TOOLS.items():
173
+ self._tools[name] = _CliToolDef(args=args, description=desc, is_write=False)
174
+ for name, (args, desc) in _WRITE_TOOLS.items():
175
+ self._tools[name] = _CliToolDef(args=args, description=desc, is_write=True)
176
+
177
+ def register_custom_tool(
178
+ self,
179
+ name: str,
180
+ handler: Any,
181
+ description: str,
182
+ input_schema: dict[str, Any],
183
+ *,
184
+ is_write: bool = False,
185
+ ) -> None:
186
+ """Register a tool backed by a direct callable.
187
+
188
+ Unlike CLI-based tools which route through ``CliRunner``, custom
189
+ tools call *handler* directly with the arguments dict and expect
190
+ a dict return value (serialised to JSON by the MCP wrapper).
191
+ """
192
+ self._custom_tools[name] = _CustomToolDef(
193
+ handler=handler,
194
+ description=description,
195
+ input_schema=input_schema,
196
+ is_write=is_write,
197
+ )
198
+
199
+ def list_tools(self) -> list[dict[str, Any]]:
200
+ """Return MCP tool descriptors."""
201
+ tools = []
202
+ for name, defn in sorted(self._tools.items()):
203
+ tool: dict[str, Any] = {
204
+ "name": name,
205
+ "description": defn.description,
206
+ "inputSchema": {
207
+ "type": "object",
208
+ "properties": {
209
+ "vin": {
210
+ "type": "string",
211
+ "description": "Vehicle VIN (optional if TESLA_VIN set)",
212
+ },
213
+ "args": {
214
+ "type": "array",
215
+ "items": {"type": "string"},
216
+ "description": "Additional CLI arguments",
217
+ },
218
+ },
219
+ },
220
+ }
221
+ tool["annotations"] = {"readOnlyHint": not defn.is_write}
222
+ tools.append(tool)
223
+ for name, cdefn in sorted(self._custom_tools.items()):
224
+ tools.append(
225
+ {
226
+ "name": name,
227
+ "description": cdefn.description,
228
+ "inputSchema": cdefn.input_schema,
229
+ "annotations": {"readOnlyHint": not cdefn.is_write},
230
+ }
231
+ )
232
+ return tools
233
+
234
+ def invoke_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
235
+ """Invoke a tool by running the corresponding CLI command.
236
+
237
+ Custom tools (registered via :meth:`register_custom_tool`) call the
238
+ handler directly. CLI tools route through ``CliRunner``.
239
+
240
+ Returns the parsed JSON output or an error dict.
241
+ """
242
+ if name in self._custom_tools:
243
+ cdefn = self._custom_tools[name]
244
+ try:
245
+ return cdefn.handler(arguments)
246
+ except KeyError as exc:
247
+ logger.info("Custom tool %s missing parameter: %s", name, exc)
248
+ return {"error": f"Missing required parameter: {exc}"}
249
+ except Exception as exc:
250
+ logger.warning("Custom tool %s failed: %s", name, exc, exc_info=True)
251
+ return {"error": str(exc)}
252
+
253
+ if name not in self._tools:
254
+ logger.info("Unknown tool: %s", name)
255
+ return {"error": f"Unknown tool: {name}"}
256
+
257
+ defn = self._tools[name]
258
+ args_template = defn.args
259
+
260
+ # Build CLI args
261
+ cli_args = ["--format", "json", "--wake"]
262
+
263
+ vin = arguments.get("vin")
264
+ if vin:
265
+ cli_args.extend(["--vin", vin])
266
+
267
+ cli_args.extend(args_template)
268
+
269
+ extra_args = arguments.get("args", [])
270
+ if extra_args:
271
+ cli_args.extend(extra_args)
272
+
273
+ logger.info("Invoke: %s", name)
274
+
275
+ import os
276
+
277
+ from tescmd.cli.main import cli
278
+
279
+ runner = _make_cli_runner()
280
+ result = runner.invoke(cli, cli_args, env=os.environ.copy())
281
+
282
+ output = result.output.strip()
283
+ if result.exit_code != 0:
284
+ logger.info("Tool %s failed (exit=%d)", name, result.exit_code)
285
+ # CliRunner catches exceptions in result.exception rather than
286
+ # routing them through main()'s error handler. Prefer the
287
+ # exception message over stderr (which may only contain httpx logs).
288
+ exc_msg = str(result.exception) if result.exception else ""
289
+ error_output = exc_msg or (result.stderr.strip() if result.stderr else output)
290
+ return {
291
+ "error": error_output or f"Command failed with exit code {result.exit_code}",
292
+ "exit_code": result.exit_code,
293
+ }
294
+
295
+ # Parse JSON output
296
+ try:
297
+ return json.loads(output) # type: ignore[no-any-return]
298
+ except json.JSONDecodeError:
299
+ return {"output": output}
300
+
301
+ async def run_stdio(self) -> None:
302
+ """Run the MCP server on stdio transport."""
303
+ from mcp.server.fastmcp import FastMCP
304
+
305
+ mcp = FastMCP("tescmd", instructions="Tesla vehicle management via Fleet API")
306
+
307
+ for name, defn in self._tools.items():
308
+ self._register_fastmcp_tool(mcp, name, defn.description)
309
+ for name, cdefn in self._custom_tools.items():
310
+ self._register_custom_fastmcp_tool(mcp, name, cdefn.description)
311
+
312
+ await mcp.run_stdio_async()
313
+
314
+ def create_http_app(
315
+ self, *, host: str = "127.0.0.1", port: int = 8080, public_url: str | None = None
316
+ ) -> Any:
317
+ """Build and return the MCP Starlette app (without starting uvicorn).
318
+
319
+ Returns the ``Starlette`` ASGI application with auth routes and
320
+ MCP tools registered. Callers can mount additional routes (e.g.
321
+ a telemetry WebSocket handler) alongside it before running uvicorn.
322
+ """
323
+ from urllib.parse import urlparse
324
+
325
+ from mcp.server.auth.settings import (
326
+ AuthSettings,
327
+ ClientRegistrationOptions,
328
+ RevocationOptions,
329
+ )
330
+ from mcp.server.fastmcp import FastMCP
331
+ from mcp.server.transport_security import TransportSecuritySettings
332
+ from pydantic import AnyHttpUrl
333
+
334
+ base_url = public_url or f"http://127.0.0.1:{port}"
335
+ provider = _InMemoryOAuthProvider(
336
+ client_id=self._client_id,
337
+ client_secret=self._client_secret,
338
+ )
339
+
340
+ # Build allowed hosts: always include localhost, add public hostname
341
+ # when exposed via Tailscale Funnel or similar reverse proxy.
342
+ allowed_hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
343
+ allowed_origins = [
344
+ "http://127.0.0.1:*",
345
+ "http://localhost:*",
346
+ "http://[::1]:*",
347
+ ]
348
+ if public_url:
349
+ parsed = urlparse(public_url)
350
+ if parsed.hostname:
351
+ allowed_hosts.append(parsed.hostname)
352
+ allowed_hosts.append(f"{parsed.hostname}:*")
353
+ allowed_origins.append(f"{parsed.scheme}://{parsed.hostname}")
354
+ allowed_origins.append(f"{parsed.scheme}://{parsed.hostname}:*")
355
+
356
+ mcp = FastMCP(
357
+ "tescmd",
358
+ instructions="Tesla vehicle management via Fleet API",
359
+ host=host,
360
+ port=port,
361
+ auth_server_provider=provider,
362
+ auth=AuthSettings(
363
+ issuer_url=AnyHttpUrl(base_url),
364
+ resource_server_url=AnyHttpUrl(base_url),
365
+ client_registration_options=ClientRegistrationOptions(enabled=True),
366
+ revocation_options=RevocationOptions(enabled=True),
367
+ ),
368
+ transport_security=TransportSecuritySettings(
369
+ enable_dns_rebinding_protection=True,
370
+ allowed_hosts=allowed_hosts,
371
+ allowed_origins=allowed_origins,
372
+ ),
373
+ )
374
+
375
+ for name, defn in self._tools.items():
376
+ self._register_fastmcp_tool(mcp, name, defn.description)
377
+ for name, cdefn in self._custom_tools.items():
378
+ self._register_custom_fastmcp_tool(mcp, name, cdefn.description)
379
+
380
+ return mcp.streamable_http_app()
381
+
382
+ async def run_http(
383
+ self, *, host: str = "127.0.0.1", port: int = 8080, public_url: str | None = None
384
+ ) -> None:
385
+ """Run the MCP server on streamable-http transport."""
386
+ import uvicorn
387
+
388
+ app = self.create_http_app(host=host, port=port, public_url=public_url)
389
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
390
+ server = uvicorn.Server(config)
391
+ await server.serve()
392
+
393
+ def _register_fastmcp_tool(
394
+ self,
395
+ mcp: Any,
396
+ tool_name: str,
397
+ description: str,
398
+ ) -> None:
399
+ """Register a CLI-backed tool with the FastMCP server."""
400
+ server = self
401
+
402
+ @mcp.tool(name=tool_name, description=description) # type: ignore
403
+ async def _tool(vin: str = "", args: list[str] | None = None) -> str:
404
+ result = await asyncio.to_thread(
405
+ server.invoke_tool, tool_name, {"vin": vin, "args": args or []}
406
+ )
407
+ return json.dumps(result, default=str, indent=2)
408
+
409
+ def _register_custom_fastmcp_tool(
410
+ self,
411
+ mcp: Any,
412
+ tool_name: str,
413
+ description: str,
414
+ ) -> None:
415
+ """Register a custom callable tool with the FastMCP server.
416
+
417
+ Custom tools accept a JSON ``params`` string rather than the
418
+ standard ``vin``/``args`` signature used by CLI tools.
419
+ """
420
+ server = self
421
+
422
+ @mcp.tool(name=tool_name, description=description) # type: ignore
423
+ def _tool(params: str = "{}") -> str:
424
+ try:
425
+ arguments = json.loads(params) if params else {}
426
+ except (json.JSONDecodeError, TypeError) as exc:
427
+ return json.dumps({"error": f"Invalid params JSON: {exc}"}, indent=2)
428
+ result = server.invoke_tool(tool_name, arguments)
429
+ return json.dumps(result, default=str, indent=2)
430
+
431
+
432
+ class _PermissiveClient:
433
+ """OAuth client wrapper that accepts any redirect URI and scope.
434
+
435
+ Used for auto-created clients on personal MCP servers where the operator
436
+ has already consented by starting the server. Wraps the real
437
+ ``OAuthClientInformationFull`` model but overrides validation to be
438
+ permissive — access control is at the network layer, not the OAuth layer.
439
+ """
440
+
441
+ def __init__(self, *, client_id: str, client_secret: str | None = None) -> None:
442
+ from mcp.shared.auth import OAuthClientInformationFull
443
+ from pydantic import AnyUrl
444
+
445
+ self._inner = OAuthClientInformationFull(
446
+ client_id=client_id,
447
+ client_secret=client_secret,
448
+ redirect_uris=[AnyUrl("https://placeholder.invalid")],
449
+ token_endpoint_auth_method="client_secret_post" if client_secret else "none",
450
+ )
451
+
452
+ def validate_redirect_uri(self, redirect_uri: Any) -> Any:
453
+ """Accept any redirect URI."""
454
+ if redirect_uri is not None:
455
+ return redirect_uri
456
+ return self._inner.redirect_uris[0] # type: ignore[index]
457
+
458
+ def validate_scope(self, requested_scope: Any) -> list[str]:
459
+ """Accept any scope."""
460
+ if isinstance(requested_scope, str):
461
+ return requested_scope.split()
462
+ return []
463
+
464
+ def __getattr__(self, name: str) -> Any:
465
+ return getattr(self._inner, name)
466
+
467
+
468
+ class _InMemoryOAuthProvider:
469
+ """In-memory OAuth 2.1 authorization server for personal MCP servers.
470
+
471
+ Auto-approves all authorization requests — access control is handled
472
+ at the network layer (Tailscale Funnel, localhost, etc.).
473
+
474
+ Implements the ``OAuthAuthorizationServerProvider`` protocol with
475
+ dynamic client registration and in-memory token storage. Unknown
476
+ ``client_id`` values are auto-created as permissive clients so MCP
477
+ clients that skip dynamic registration (e.g. Claude.ai) still work.
478
+ """
479
+
480
+ def __init__(
481
+ self,
482
+ *,
483
+ client_id: str | None = None,
484
+ client_secret: str | None = None,
485
+ ) -> None:
486
+ self._configured_client_id = client_id
487
+ self._configured_client_secret = client_secret
488
+ self._clients: dict[str, Any] = {}
489
+ self._auth_codes: dict[str, Any] = {}
490
+ self._access_tokens: dict[str, Any] = {}
491
+ self._refresh_tokens: dict[str, Any] = {}
492
+
493
+ async def get_client(self, client_id: str) -> Any:
494
+ client = self._clients.get(client_id)
495
+ if client is not None:
496
+ return client
497
+ # Auto-create a permissive client for any unknown client_id.
498
+ # This is safe because network-level access control (Tailscale,
499
+ # localhost) gates who can reach the server in the first place.
500
+ # If this is the configured client, attach the secret so token
501
+ # endpoint authentication succeeds.
502
+ secret = (
503
+ self._configured_client_secret if client_id == self._configured_client_id else None
504
+ )
505
+ permissive = _PermissiveClient(client_id=client_id, client_secret=secret)
506
+ self._clients[client_id] = permissive
507
+ return permissive
508
+
509
+ async def register_client(self, client_info: Any) -> None:
510
+ self._clients[client_info.client_id] = client_info
511
+
512
+ async def authorize(self, client: Any, params: Any) -> str:
513
+ import secrets
514
+ import time
515
+ from urllib.parse import urlencode, urlparse, urlunparse
516
+
517
+ from mcp.server.auth.provider import AuthorizationCode
518
+
519
+ code = secrets.token_urlsafe(32)
520
+ self._auth_codes[code] = AuthorizationCode(
521
+ code=code,
522
+ scopes=params.scopes or [],
523
+ expires_at=time.time() + 300,
524
+ client_id=client.client_id,
525
+ code_challenge=params.code_challenge,
526
+ redirect_uri=params.redirect_uri,
527
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
528
+ resource=params.resource,
529
+ )
530
+
531
+ parsed = urlparse(str(params.redirect_uri))
532
+ query_parts = [parsed.query] if parsed.query else []
533
+ extra: dict[str, str] = {"code": code}
534
+ if params.state:
535
+ extra["state"] = params.state
536
+ query_parts.append(urlencode(extra))
537
+ return urlunparse(parsed._replace(query="&".join(query_parts)))
538
+
539
+ async def load_authorization_code(
540
+ self,
541
+ client: Any,
542
+ authorization_code: str,
543
+ ) -> Any:
544
+ code_obj = self._auth_codes.get(authorization_code)
545
+ if code_obj is not None and code_obj.client_id == client.client_id:
546
+ return code_obj
547
+ return None
548
+
549
+ async def exchange_authorization_code(
550
+ self,
551
+ client: Any,
552
+ authorization_code: Any,
553
+ ) -> Any:
554
+ import secrets
555
+ import time
556
+
557
+ from mcp.server.auth.provider import AccessToken, RefreshToken
558
+ from mcp.shared.auth import OAuthToken
559
+
560
+ self._auth_codes.pop(authorization_code.code, None)
561
+
562
+ access_str = secrets.token_urlsafe(32)
563
+ refresh_str = secrets.token_urlsafe(32)
564
+
565
+ self._access_tokens[access_str] = AccessToken(
566
+ token=access_str,
567
+ client_id=client.client_id,
568
+ scopes=authorization_code.scopes,
569
+ expires_at=int(time.time()) + 3600,
570
+ resource=authorization_code.resource,
571
+ )
572
+ self._refresh_tokens[refresh_str] = RefreshToken(
573
+ token=refresh_str,
574
+ client_id=client.client_id,
575
+ scopes=authorization_code.scopes,
576
+ )
577
+
578
+ return OAuthToken(
579
+ access_token=access_str,
580
+ token_type="Bearer",
581
+ expires_in=3600,
582
+ scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
583
+ refresh_token=refresh_str,
584
+ )
585
+
586
+ async def load_access_token(self, token: str) -> Any:
587
+ return self._access_tokens.get(token)
588
+
589
+ async def load_refresh_token(
590
+ self,
591
+ client: Any,
592
+ refresh_token: str,
593
+ ) -> Any:
594
+ rt = self._refresh_tokens.get(refresh_token)
595
+ if rt is not None and rt.client_id == client.client_id:
596
+ return rt
597
+ return None
598
+
599
+ async def exchange_refresh_token(
600
+ self,
601
+ client: Any,
602
+ refresh_token: Any,
603
+ scopes: list[str],
604
+ ) -> Any:
605
+ import secrets
606
+ import time
607
+
608
+ from mcp.server.auth.provider import AccessToken, RefreshToken
609
+ from mcp.shared.auth import OAuthToken
610
+
611
+ self._refresh_tokens.pop(refresh_token.token, None)
612
+
613
+ new_access = secrets.token_urlsafe(32)
614
+ new_refresh = secrets.token_urlsafe(32)
615
+ used_scopes = scopes or refresh_token.scopes
616
+
617
+ self._access_tokens[new_access] = AccessToken(
618
+ token=new_access,
619
+ client_id=client.client_id,
620
+ scopes=used_scopes,
621
+ expires_at=int(time.time()) + 3600,
622
+ )
623
+ self._refresh_tokens[new_refresh] = RefreshToken(
624
+ token=new_refresh,
625
+ client_id=client.client_id,
626
+ scopes=used_scopes,
627
+ )
628
+
629
+ return OAuthToken(
630
+ access_token=new_access,
631
+ token_type="Bearer",
632
+ expires_in=3600,
633
+ scope=" ".join(used_scopes) if used_scopes else None,
634
+ refresh_token=new_refresh,
635
+ )
636
+
637
+ async def revoke_token(self, token: Any) -> None:
638
+ from mcp.server.auth.provider import AccessToken, RefreshToken
639
+
640
+ if isinstance(token, AccessToken):
641
+ self._access_tokens.pop(token.token, None)
642
+ elif isinstance(token, RefreshToken):
643
+ self._refresh_tokens.pop(token.token, None)
644
+
645
+
646
+ def create_mcp_server(*, client_id: str, client_secret: str) -> MCPServer:
647
+ """Factory function to create a configured MCP server."""
648
+ return MCPServer(client_id=client_id, client_secret=client_secret)
tescmd/models/auth.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import base64
4
4
  import json
5
5
  import logging
6
+ import os as _os
6
7
  from typing import Any
7
8
 
8
9
  from pydantic import BaseModel
@@ -44,10 +45,10 @@ DEFAULT_SCOPES: list[str] = [
44
45
  *USER_SCOPES,
45
46
  ]
46
47
 
47
- DEFAULT_PORT: int = 8085
48
+ DEFAULT_PORT: int = int(_os.environ.get("TESCMD_OAUTH_PORT", "8085"))
48
49
  DEFAULT_REDIRECT_URI: str = f"http://localhost:{DEFAULT_PORT}/callback"
49
50
 
50
- AUTH_BASE_URL: str = "https://auth.tesla.com"
51
+ AUTH_BASE_URL: str = _os.environ.get("TESLA_AUTH_BASE_URL", "https://auth.tesla.com")
51
52
  AUTHORIZE_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/authorize"
52
53
  TOKEN_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/token"
53
54
 
@@ -97,6 +98,8 @@ def decode_jwt_scopes(token: str) -> list[str] | None:
97
98
  scp = payload.get("scp")
98
99
  if isinstance(scp, list):
99
100
  return [str(s) for s in scp]
101
+ if isinstance(scp, str):
102
+ return scp.split()
100
103
  return None
101
104
 
102
105