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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {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
|
|