mcpforunityserver 9.3.0b20260129104751__py3-none-any.whl → 9.3.0b20260131003150__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 (30) hide show
  1. cli/utils/connection.py +28 -32
  2. core/config.py +15 -0
  3. core/constants.py +4 -0
  4. main.py +306 -174
  5. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/METADATA +117 -5
  6. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/RECORD +30 -28
  7. models/__init__.py +2 -2
  8. models/unity_response.py +24 -1
  9. services/api_key_service.py +235 -0
  10. services/resources/active_tool.py +2 -1
  11. services/resources/editor_state.py +7 -7
  12. services/resources/layers.py +2 -1
  13. services/resources/menu_items.py +2 -1
  14. services/resources/prefab_stage.py +2 -1
  15. services/resources/project_info.py +2 -1
  16. services/resources/selection.py +2 -1
  17. services/resources/tags.py +2 -1
  18. services/resources/tests.py +3 -2
  19. services/resources/unity_instances.py +6 -3
  20. services/resources/windows.py +2 -1
  21. services/tools/manage_prefabs.py +35 -0
  22. services/tools/set_active_instance.py +6 -3
  23. transport/plugin_hub.py +124 -24
  24. transport/plugin_registry.py +75 -19
  25. transport/unity_instance_middleware.py +38 -9
  26. transport/unity_transport.py +41 -10
  27. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/WHEEL +0 -0
  28. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/entry_points.txt +0 -0
  29. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/licenses/LICENSE +0 -0
  30. {mcpforunityserver-9.3.0b20260129104751.dist-info → mcpforunityserver-9.3.0b20260131003150.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpforunityserver
3
- Version: 9.3.0b20260129104751
3
+ Version: 9.3.0b20260131003150
4
4
  Summary: MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP).
5
5
  Author-email: Marcus Sanatan <msanatan@gmail.com>, David Sarno <david.sarno@gmail.com>, Wu Shutong <martinwfire@gmail.com>
6
6
  License-Expression: MIT
@@ -154,12 +154,124 @@ uv run src/main.py --transport stdio
154
154
 
155
155
  ## Configuration
156
156
 
157
- The server connects to Unity Editor automatically when both are running. No additional configuration needed.
157
+ The server connects to Unity Editor automatically when both are running. Most users do not need to change any settings.
158
+
159
+ ### CLI options
160
+
161
+ These options apply to the `mcp-for-unity` command (whether run via `uvx`, Docker, or `python src/main.py`).
162
+
163
+ - `--transport {stdio,http}` - Transport protocol (default: `stdio`)
164
+ - `--http-url URL` - Base URL used to derive host/port defaults (default: `http://localhost:8080`)
165
+ - `--http-host HOST` - Override HTTP bind host (overrides URL host)
166
+ - `--http-port PORT` - Override HTTP bind port (overrides URL port)
167
+ - `--http-remote-hosted` - Treat HTTP transport as remotely hosted
168
+ - Requires API key authentication (see below)
169
+ - Disables local/CLI-only HTTP routes (`/api/command`, `/api/instances`, `/api/custom-tools`)
170
+ - Forces explicit Unity instance selection for MCP tool/resource calls
171
+ - Isolates Unity sessions per user
172
+ - `--api-key-validation-url URL` - External endpoint to validate API keys (required when `--http-remote-hosted` is set)
173
+ - `--api-key-login-url URL` - URL where users can obtain/manage API keys (served by `/api/auth/login-url`)
174
+ - `--api-key-cache-ttl SECONDS` - Cache duration for validated keys (default: `300`)
175
+ - `--api-key-service-token-header HEADER` - Header name for server-to-auth-service authentication (e.g. `X-Service-Token`)
176
+ - `--api-key-service-token TOKEN` - Token value sent to the auth service for server authentication
177
+ - `--default-instance INSTANCE` - Default Unity instance to target (project name, hash, or `Name@hash`)
178
+ - `--project-scoped-tools` - Keep custom tools scoped to the active Unity project and enable the custom tools resource
179
+ - `--unity-instance-token TOKEN` - Optional per-launch token set by Unity for deterministic lifecycle management
180
+ - `--pidfile PATH` - Optional path where the server writes its PID on startup (used by Unity-managed terminal launches)
181
+
182
+ ### Environment variables
183
+
184
+ - `UNITY_MCP_TRANSPORT` - Transport protocol: `stdio` or `http`
185
+ - `UNITY_MCP_HTTP_URL` - HTTP server URL (default: `http://localhost:8080`)
186
+ - `UNITY_MCP_HTTP_HOST` - HTTP bind host (overrides URL host)
187
+ - `UNITY_MCP_HTTP_PORT` - HTTP bind port (overrides URL port)
188
+ - `UNITY_MCP_HTTP_REMOTE_HOSTED` - Enable remote-hosted mode (`true`, `1`, or `yes`)
189
+ - `UNITY_MCP_DEFAULT_INSTANCE` - Default Unity instance to target (project name, hash, or `Name@hash`)
190
+ - `UNITY_MCP_SKIP_STARTUP_CONNECT=1` - Skip initial Unity connection attempt on startup
191
+
192
+ API key authentication (remote-hosted mode):
193
+
194
+ - `UNITY_MCP_API_KEY_VALIDATION_URL` - External endpoint to validate API keys
195
+ - `UNITY_MCP_API_KEY_LOGIN_URL` - URL where users can obtain/manage API keys
196
+ - `UNITY_MCP_API_KEY_CACHE_TTL` - Cache TTL for validated keys in seconds (default: `300`)
197
+ - `UNITY_MCP_API_KEY_SERVICE_TOKEN_HEADER` - Header name for server-to-auth-service authentication
198
+ - `UNITY_MCP_API_KEY_SERVICE_TOKEN` - Token value sent to the auth service for server authentication
199
+
200
+ Telemetry:
201
+
202
+ - `DISABLE_TELEMETRY=1` - Disable anonymous telemetry (opt-out)
203
+ - `UNITY_MCP_DISABLE_TELEMETRY=1` - Same as `DISABLE_TELEMETRY`
204
+ - `MCP_DISABLE_TELEMETRY=1` - Same as `DISABLE_TELEMETRY`
205
+ - `UNITY_MCP_TELEMETRY_ENDPOINT` - Override telemetry endpoint URL
206
+ - `UNITY_MCP_TELEMETRY_TIMEOUT` - Override telemetry request timeout (seconds)
207
+
208
+ ### Examples
209
+
210
+ **Stdio (default):**
158
211
 
159
- **Environment Variables:**
212
+ ```bash
213
+ uvx --from mcpforunityserver mcp-for-unity --transport stdio
214
+ ```
215
+
216
+ **HTTP (local):**
217
+
218
+ ```bash
219
+ uvx --from mcpforunityserver mcp-for-unity --transport http --http-host 127.0.0.1 --http-port 8080
220
+ ```
221
+
222
+ **HTTP (remote-hosted with API key auth):**
223
+
224
+ ```bash
225
+ uvx --from mcpforunityserver mcp-for-unity \
226
+ --transport http \
227
+ --http-host 0.0.0.0 \
228
+ --http-port 8080 \
229
+ --http-remote-hosted \
230
+ --api-key-validation-url https://auth.example.com/api/validate-key \
231
+ --api-key-login-url https://app.example.com/api-keys
232
+ ```
233
+
234
+ **Disable telemetry:**
235
+
236
+ ```bash
237
+ DISABLE_TELEMETRY=1 uvx --from mcpforunityserver mcp-for-unity --transport stdio
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Remote-Hosted Mode
243
+
244
+ When deploying the server as a shared remote service (e.g. for a team or Asset Store users), enable `--http-remote-hosted` to activate API key authentication and per-user session isolation.
245
+
246
+ **Requirements:**
247
+
248
+ - An external HTTP endpoint that validates API keys. The server POSTs `{"api_key": "..."}` and expects `{"valid": true, "user_id": "..."}` or `{"valid": false}` in response.
249
+ - `--api-key-validation-url` must be provided (or `UNITY_MCP_API_KEY_VALIDATION_URL`). The server exits with code 1 if this is missing.
250
+
251
+ **What changes in remote-hosted mode:**
252
+
253
+ - All MCP tool/resource calls and Unity plugin WebSocket connections require a valid `X-API-Key` header.
254
+ - Each user only sees Unity instances that connected with their API key (session isolation).
255
+ - Auto-selection of a sole Unity instance is disabled; users must explicitly call `set_active_instance`.
256
+ - CLI REST routes (`/api/command`, `/api/instances`, `/api/custom-tools`) are disabled.
257
+ - `/health` and `/api/auth/login-url` remain accessible without authentication.
258
+
259
+ **MCP client config with API key:**
260
+
261
+ ```json
262
+ {
263
+ "mcpServers": {
264
+ "UnityMCP": {
265
+ "url": "http://remote-server:8080/mcp",
266
+ "headers": {
267
+ "X-API-Key": "<your-api-key>"
268
+ }
269
+ }
270
+ }
271
+ }
272
+ ```
160
273
 
161
- - `DISABLE_TELEMETRY=true` - Opt out of anonymous usage analytics
162
- - `LOG_LEVEL=DEBUG` - Enable detailed logging (default: INFO)
274
+ For full details, see [Remote Server Auth Guide](../docs/guides/REMOTE_SERVER_AUTH.md) and [Architecture Reference](../docs/reference/REMOTE_SERVER_AUTH_ARCHITECTURE.md).
163
275
 
164
276
  ---
165
277
 
@@ -1,4 +1,4 @@
1
- main.py,sha256=EoHA0upWjtQzuoOgN5BfNmGL6bIVnFQjRW5PHO5EmjY,29265
1
+ main.py,sha256=xNuZrgxhsjVOYl1C-NBzpW-eb8YW3_m_E2Npz9a5vao,35040
2
2
  cli/__init__.py,sha256=f2HjXqR9d8Uhibru211t9HPpdrb_1vdDC2v_NwF_eqA,63
3
3
  cli/main.py,sha256=V_VFa8tA-CDHNv9J5NzNSLxRuEGjRVZWDe4xn6rYdog,8457
4
4
  cli/commands/__init__.py,sha256=xQHf6o0afDV2HsU9gwSxjcrzS41cMCSGZyWYWxblPIk,69
@@ -24,40 +24,42 @@ cli/commands/vfx.py,sha256=gdx5a_N7Ulu960xjaJIV0E_1Ii422C211KaipoRO6nQ,14453
24
24
  cli/utils/__init__.py,sha256=Gbm9hYC7UqwloFwdirXgo6z1iBktR9Y96o3bQcrYudc,613
25
25
  cli/utils/config.py,sha256=_k3XAFmXG22sv8tYIb5JmO46kNl3T1sGqFptySAayfc,1550
26
26
  cli/utils/confirmation.py,sha256=7NGu0I5ogowpdWRTUndn3g5nmWmJM9mV3e0wWMLJwA8,1234
27
- cli/utils/connection.py,sha256=RBSOK7WotKgokj-wewsqnKT5rwS0_DhsqXqZrFZWUXo,8534
27
+ cli/utils/connection.py,sha256=fx2o3J9AI6HdSC6rRDKh6KO8sPmBWkzR-ElY1dDkhes,8123
28
28
  cli/utils/constants.py,sha256=xCyRMY1L3cc-sbCyl-TGyqkY5hMCOl5tU6L4ZVbN9w4,1046
29
29
  cli/utils/output.py,sha256=96daU55ta_hl7UeOhNh5Iy7OJ4psbdR9Nfx1-q2k3xA,6370
30
30
  cli/utils/parsers.py,sha256=mnpH2bhZn3L3Lyl8e7Sh7Zr2UW9Xzu3aUuCaLxpV2pk,3430
31
31
  cli/utils/suggestions.py,sha256=n6KG3Mrvub28X9rPFYFLRTtZ6HePp3PhhAeojG2WOJw,929
32
32
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
- core/config.py,sha256=kE7yg_89QQck3zScWNjvrq7CnfYt6uLS0VdxK7AJ70o,1453
33
+ core/config.py,sha256=VlCLUFPm73DfInCVZkJFq4_7KZKVqSFbDS-n0HMA8WA,2118
34
+ core/constants.py,sha256=PhZs926Nie7Muigju1xkVBynVaUktZlP9lJMHgu4_to,114
34
35
  core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
35
36
  core/telemetry.py,sha256=zIjmQKUNW0S822SSlkXyjjCIuX0ZpSTaZP4pAU0rCjw,20426
36
37
  core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
37
- mcpforunityserver-9.3.0b20260129104751.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
38
- models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
38
+ mcpforunityserver-9.3.0b20260131003150.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
39
+ models/__init__.py,sha256=J2ozraI5aDkqLb53KvXjTDaWgh_xVFxpWrcRMekOwPk,231
39
40
  models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
40
- models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
41
+ models/unity_response.py,sha256=xvgJhJ3pS8-ATQhnU5owijgO4141UW59f5dQm1ohVew,2278
41
42
  services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
+ services/api_key_service.py,sha256=Wq8BJDHXurN0hSiotqI37ta7G-Q9QAxHCD5hHbokxYs,8755
42
44
  services/custom_tool_service.py,sha256=WJxljL-hdJE5GMlAhVimHVhQwwnWHCd0StgWhWEFgaI,18592
43
45
  services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
44
46
  services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
45
47
  services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
46
48
  services/resources/__init__.py,sha256=G8uSEYJtiyX3yg0QsfoeGdDXOdbU89l5m0B5Anay1Fc,3054
47
- services/resources/active_tool.py,sha256=6m65iPCD1Iqmp4xqpDKvPErfOJg7Gl3XU5vMyAVcdj8,1521
49
+ services/resources/active_tool.py,sha256=O2tKtId1-UCZXFAVigIN4WzuiihmyZe24sCGn1k16HA,1558
48
50
  services/resources/custom_tools.py,sha256=uujlJEuqv5svCvSZLILgiY6hiiWZqWHVzTBJpTTL3as,1751
49
- services/resources/editor_state.py,sha256=LZLmljdHQC7nQcA-YYtoRnpy1xDiiAzo4_iU69TgsH8,10858
51
+ services/resources/editor_state.py,sha256=OEPdb7VFFzk6LGN1r5FouBFv4sQdfKs-8sMKZxXCHwA,11004
50
52
  services/resources/gameobject.py,sha256=cviektt_GHmwgria5pkvNrzvD5-6hzBi64Ogm0YGIv8,9356
51
- services/resources/layers.py,sha256=e3bDhfJ0ZjtUagHhraI35GrzrDnzXrRHnDeobmnbniw,1123
52
- services/resources/menu_items.py,sha256=01LIVHA96bswYGXyLZzGFw3suDqftsBanhy_lkUB2H0,1054
53
+ services/resources/layers.py,sha256=6CjGfHH2XqLX6FYAoBLgePZH3srVO-9psZV8TJ93Okw,1160
54
+ services/resources/menu_items.py,sha256=tNyHlqC9mFkllN3JW9fyouecTvnV4YbnlSAysbAjVbQ,1091
53
55
  services/resources/prefab.py,sha256=4TLEBsrlnQdi5FOKWZZ9eesiUCG5Ryxh7FZvd02FcTc,7434
54
- services/resources/prefab_stage.py,sha256=YALZrfZh4zpV_tEfQI7sUR6ETLnz8qeTN--gwYxk_y8,1430
55
- services/resources/project_info.py,sha256=NaHb9v5n-2vi3woMxioMXbgpy1tBcmh2bh0RZOTxtC4,1364
56
- services/resources/selection.py,sha256=HxmpN0RAb6Op0r4kqqRsizahP5L3Hj8V1baRa3uaKpU,1884
57
- services/resources/tags.py,sha256=f50DzILLNQsUJ44ooomwIGasrcntbKJgT7boqrU4b5s,1090
58
- services/resources/tests.py,sha256=KOm1vFcDPAXi21rDRQ1hQmifs1T9Xnd0zUp-BcBrBxo,3458
59
- services/resources/unity_instances.py,sha256=8vLA5oR2VpeLRjuxHk-_J3eopLhuNpGEoJyCvyUAOXg,4386
60
- services/resources/windows.py,sha256=inD7zMB3R4Tip_92pCDtKGD7clzsGKOGkdmv4rKmygA,1470
56
+ services/resources/prefab_stage.py,sha256=APVPbzxSO4vUR0YgYDi4WI_woywqoc0KcTyUwgbGmJQ,1467
57
+ services/resources/project_info.py,sha256=xGSFTxOs4y7ok7iO3XVdGhxxyjAXpg1czbapybqp-NI,1401
58
+ services/resources/selection.py,sha256=E2thI4vWqk2SE3gm6mcDvYWSG0UuRpC1tja2Y6J2BRc,1921
59
+ services/resources/tags.py,sha256=fROK2Bj9VBiyQSQt0jdXxYatX-3d4vtAH8i6Zhgj5Vw,1127
60
+ services/resources/tests.py,sha256=6aqhZWxO6YPMGBzaw_dxMUm1wCJMFRNLeu6Nf_wXieo,3474
61
+ services/resources/unity_instances.py,sha256=Ix6YtTWKnTLXZ4v4mrn_d0IhLr7gcjfe9d605zf2V5w,4564
62
+ services/resources/windows.py,sha256=UT8-CDstsBXyggzJ8A9pVFIqNr8jr9oP77rOEBsWQCk,1507
61
63
  services/state/external_changes_scanner.py,sha256=ZiXu8ZcK5B-hv7CaJLmnEIa9JxzgOBpdmrsRDY2eK5I,9052
62
64
  services/tools/__init__.py,sha256=J-woLzm3aLF0uPC1-VroqG7QV9xLXHDmVYee2ZXuCgk,2746
63
65
  services/tools/batch_execute.py,sha256=hjh67kgWvQDHyGd2N-Tfezv9WAj5x_pWTt_Vybmmq7s,3501
@@ -71,7 +73,7 @@ services/tools/manage_components.py,sha256=2_nKPk9iPAf5VyYiXuRxSkN8U76VNQbMtE68U
71
73
  services/tools/manage_editor.py,sha256=ShvlSBQRfoNQ0DvqBWak_Hi3MB7tv2WkMKEhrKQipk0,3279
72
74
  services/tools/manage_gameobject.py,sha256=SP-y3_7Ckw12JO6bLJy01Jrx303JXF-7RMROaQuA1kU,14154
73
75
  services/tools/manage_material.py,sha256=LSn9Kp-cSMZ5caU6Ux0M_OSMghCtZgOKkmvwf0xLTFE,4311
74
- services/tools/manage_prefabs.py,sha256=VKm6D9wR3B3wkyXUVBD__CqUpyzgFu1dUDYsVX6tbKE,7661
76
+ services/tools/manage_prefabs.py,sha256=7K-6kTavqu2-oxbA49Ug-vTh1Jt39ua02lWcijwM1DI,10163
75
77
  services/tools/manage_scene.py,sha256=-ARtRuj7ZNk_14lmMSORnQs0qTAYKBTPtUfk0sNDo6A,5370
76
78
  services/tools/manage_script.py,sha256=tT8JmhTtAYgW8CQla71cfn6IjiUw-tiPjBWVd4ipuCE,28551
77
79
  services/tools/manage_scriptable_object.py,sha256=tezG_mbGzPLNpL3F7l5JJLyyjJN3rJi1thGMU8cpOC4,3659
@@ -83,21 +85,21 @@ services/tools/read_console.py,sha256=ps23debJcQkj3Ap-MqTYVhopYnKGspJs9QHLJHZAAk
83
85
  services/tools/refresh_unity.py,sha256=KrRA8bmLkDLFO1XBv2NmagQAp1dmyaXdUAap567Hcv4,7100
84
86
  services/tools/run_tests.py,sha256=Wd7hNZqy-OOZ9ZedonxUJ5bVlhta_aOEmD-2uxmrmyM,11743
85
87
  services/tools/script_apply_edits.py,sha256=0f-SaP5NUYGuivl4CWHjR8F-CXUpt3-5qkHpf_edn1U,47677
86
- services/tools/set_active_instance.py,sha256=pdmC1SxFijyzzjeEyC2N1bXk-GNMu_iXsbCieIpa-R4,4242
88
+ services/tools/set_active_instance.py,sha256=j_d0GhFwhBh-HwoNKzrice1haNyWPdK-RtVrNasYH7c,4408
87
89
  services/tools/utils.py,sha256=ETCiNnWdMZEtnJcDD-CtPsCJ7TBp5x5sPsYuhufkxac,13962
88
90
  transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
89
91
  transport/models.py,sha256=2tnK0Wc-IzvlOS0a04VB3WtuSlAAHeLVFfOnNFHGnhw,1311
90
- transport/plugin_hub.py,sha256=Kykqnydk_KkCSICaJGE8XQulr7sZeNcIgPOHjtdbjHM,23783
91
- transport/plugin_registry.py,sha256=L6xl7Ok9rtSW9xwSSKTFjWS7Duu1g4c-qfgfnn6y-sQ,4528
92
- transport/unity_instance_middleware.py,sha256=DD8gs-peMRmRJz9CYwaHEh4m75LTYPDjVuKuw9sArBw,10438
93
- transport/unity_transport.py,sha256=RKMH0NYPqTU3rpjXUPUkzZsdWhJqyVaHmkLswab4bGg,2135
92
+ transport/plugin_hub.py,sha256=Xe0Vn1Df7Ks4KmRs3_waheUWuaQnT_DMLELA1tCnqHs,28449
93
+ transport/plugin_registry.py,sha256=Zn2QaDbpDj9Ad-oXs3q7CXxnZz3mc5SJ61GzIToEBPI,7160
94
+ transport/unity_instance_middleware.py,sha256=vdbX28eU6ddxEiPdgtv275Q1qcIJzuyYxQnNE-KXXnc,11837
95
+ transport/unity_transport.py,sha256=MK7eG3JU5zEGF2w5ntIBqxCvLy7cv-E5tlymqcQC8UA,3319
94
96
  transport/legacy/port_discovery.py,sha256=JDSCqXLodfTT7fOsE0DFC1jJ3QsU6hVaYQb7x7FgdxY,12728
95
97
  transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
96
98
  transport/legacy/unity_connection.py,sha256=FE9ZQfYMhHvIxBycr_DjI3BKvuEdORXuABnCE5Q2tjQ,36733
97
99
  utils/focus_nudge.py,sha256=0MCOms-SxUW7sN2hT3syy1epMdli2zc-6UHBICAfBSM,21330
98
100
  utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
99
- mcpforunityserver-9.3.0b20260129104751.dist-info/METADATA,sha256=EpQqY2ZImvDyHMV7D16RFaKgDsSBd8DDvlgKWbCfoKs,6750
100
- mcpforunityserver-9.3.0b20260129104751.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
101
- mcpforunityserver-9.3.0b20260129104751.dist-info/entry_points.txt,sha256=pPm70RXQvkt3uBhPOtViDa47ZTA03RaQ6rwXvyi8oiI,70
102
- mcpforunityserver-9.3.0b20260129104751.dist-info/top_level.txt,sha256=3-A65WsmBO6UZYH8O5mINdyhhZ63SDssr8LncRd1PSQ,46
103
- mcpforunityserver-9.3.0b20260129104751.dist-info/RECORD,,
101
+ mcpforunityserver-9.3.0b20260131003150.dist-info/METADATA,sha256=9JlYrKIHsH0CAaGJUXtHikB_kUs9rWYjmJoPDYmvlkQ,11877
102
+ mcpforunityserver-9.3.0b20260131003150.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
103
+ mcpforunityserver-9.3.0b20260131003150.dist-info/entry_points.txt,sha256=pPm70RXQvkt3uBhPOtViDa47ZTA03RaQ6rwXvyi8oiI,70
104
+ mcpforunityserver-9.3.0b20260131003150.dist-info/top_level.txt,sha256=3-A65WsmBO6UZYH8O5mINdyhhZ63SDssr8LncRd1PSQ,46
105
+ mcpforunityserver-9.3.0b20260131003150.dist-info/RECORD,,
models/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  from .models import MCPResponse, UnityInstanceInfo
2
- from .unity_response import normalize_unity_response
2
+ from .unity_response import normalize_unity_response, parse_resource_response
3
3
 
4
- __all__ = ['MCPResponse', 'UnityInstanceInfo', 'normalize_unity_response']
4
+ __all__ = ['MCPResponse', 'UnityInstanceInfo', 'normalize_unity_response', 'parse_resource_response']
models/unity_response.py CHANGED
@@ -1,7 +1,9 @@
1
1
  """Utilities for normalizing Unity transport responses."""
2
2
  from __future__ import annotations
3
3
 
4
- from typing import Any
4
+ from typing import Any, Type
5
+
6
+ from models.models import MCPResponse
5
7
 
6
8
 
7
9
  def normalize_unity_response(response: Any) -> Any:
@@ -45,3 +47,24 @@ def normalize_unity_response(response: Any) -> Any:
45
47
  normalized["error"] = message or "Unity command failed"
46
48
 
47
49
  return normalized
50
+
51
+
52
+ def parse_resource_response(response: Any, typed_cls: Type[MCPResponse]) -> MCPResponse:
53
+ """Parse a Unity response into a typed response class.
54
+
55
+ Returns a base ``MCPResponse`` for error responses so that typed subclasses
56
+ with strict ``data`` fields (e.g. ``list[str]``) don't raise Pydantic
57
+ validation errors when ``data`` is ``None``.
58
+ """
59
+ if not isinstance(response, dict):
60
+ return response
61
+
62
+ # Detect errors from both normalized (success=False) and raw (status="error") shapes.
63
+ if response.get("success") is False or response.get("status") == "error":
64
+ return MCPResponse(
65
+ success=False,
66
+ error=response.get("error"),
67
+ message=response.get("message"),
68
+ )
69
+
70
+ return typed_cls(**response)
@@ -0,0 +1,235 @@
1
+ """API Key validation service for remote-hosted mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ logger = logging.getLogger("mcp-for-unity-server")
14
+
15
+
16
+ @dataclass
17
+ class ValidationResult:
18
+ """Result of an API key validation."""
19
+ valid: bool
20
+ user_id: str | None = None
21
+ metadata: dict[str, Any] | None = None
22
+ error: str | None = None
23
+ cacheable: bool = True
24
+
25
+
26
+ class ApiKeyService:
27
+ """Service for validating API keys against an external auth endpoint.
28
+
29
+ Follows the class-level singleton pattern for global access by MCP tools.
30
+ """
31
+
32
+ _instance: "ApiKeyService | None" = None
33
+
34
+ # Request defaults (sensible hardening)
35
+ REQUEST_TIMEOUT: float = 5.0
36
+ MAX_RETRIES: int = 1
37
+
38
+ def __init__(
39
+ self,
40
+ validation_url: str,
41
+ cache_ttl: float = 300.0,
42
+ service_token_header: str | None = None,
43
+ service_token: str | None = None,
44
+ ):
45
+ """Initialize the API key service.
46
+
47
+ Args:
48
+ validation_url: External URL to validate API keys (POST with {"api_key": "..."})
49
+ cache_ttl: Cache TTL for validated keys in seconds (default: 300)
50
+ service_token_header: Optional header name for service authentication (e.g. "X-Service-Token")
51
+ service_token: Optional token value for service authentication
52
+ """
53
+ self._validation_url = validation_url
54
+ self._cache_ttl = cache_ttl
55
+ self._service_token_header = service_token_header
56
+ self._service_token = service_token
57
+ # Cache: api_key -> (valid, user_id, metadata, expires_at)
58
+ self._cache: dict[str, tuple[bool, str |
59
+ None, dict[str, Any] | None, float]] = {}
60
+ self._cache_lock = asyncio.Lock()
61
+ ApiKeyService._instance = self
62
+
63
+ @classmethod
64
+ def get_instance(cls) -> "ApiKeyService":
65
+ """Get the singleton instance.
66
+
67
+ Raises:
68
+ RuntimeError: If the service has not been initialized.
69
+ """
70
+ if cls._instance is None:
71
+ raise RuntimeError("ApiKeyService not initialized")
72
+ return cls._instance
73
+
74
+ @classmethod
75
+ def is_initialized(cls) -> bool:
76
+ """Check if the service has been initialized."""
77
+ return cls._instance is not None
78
+
79
+ async def validate(self, api_key: str) -> ValidationResult:
80
+ """Validate an API key.
81
+
82
+ Returns:
83
+ ValidationResult with valid=True and user_id if valid,
84
+ or valid=False with error message if invalid.
85
+ """
86
+ if not api_key:
87
+ return ValidationResult(valid=False, error="API key required")
88
+
89
+ # Check cache first
90
+ async with self._cache_lock:
91
+ cached = self._cache.get(api_key)
92
+ if cached is not None:
93
+ valid, user_id, metadata, expires_at = cached
94
+ if time.time() < expires_at:
95
+ if valid:
96
+ return ValidationResult(valid=True, user_id=user_id, metadata=metadata)
97
+ else:
98
+ return ValidationResult(valid=False, error="Invalid API key")
99
+ else:
100
+ # Expired, remove from cache
101
+ del self._cache[api_key]
102
+
103
+ # Call external validation URL
104
+ result = await self._validate_external(api_key)
105
+
106
+ # Only cache definitive results (valid keys and confirmed-invalid keys).
107
+ # Transient failures (auth service unavailable, timeouts, etc.) should
108
+ # not be cached to avoid locking out users during service outages.
109
+ if result.cacheable:
110
+ async with self._cache_lock:
111
+ expires_at = time.time() + self._cache_ttl
112
+ self._cache[api_key] = (
113
+ result.valid,
114
+ result.user_id,
115
+ result.metadata,
116
+ expires_at,
117
+ )
118
+
119
+ return result
120
+
121
+ async def _validate_external(self, api_key: str) -> ValidationResult:
122
+ """Call external validation endpoint.
123
+
124
+ Failure mode: fail closed (treat as invalid on errors).
125
+ """
126
+ # Redact API key from logs
127
+ redacted_key = f"{api_key[:4]}...{api_key[-4:]}" if len(
128
+ api_key) > 8 else "***"
129
+
130
+ for attempt in range(self.MAX_RETRIES + 1):
131
+ try:
132
+ async with httpx.AsyncClient(timeout=self.REQUEST_TIMEOUT) as client:
133
+ # Build request headers
134
+ headers = {"Content-Type": "application/json"}
135
+ if self._service_token_header and self._service_token:
136
+ headers[self._service_token_header] = self._service_token
137
+
138
+ response = await client.post(
139
+ self._validation_url,
140
+ json={"api_key": api_key},
141
+ headers=headers,
142
+ )
143
+
144
+ if response.status_code == 200:
145
+ data = response.json()
146
+ if data.get("valid"):
147
+ return ValidationResult(
148
+ valid=True,
149
+ user_id=data.get("user_id"),
150
+ metadata=data.get("metadata"),
151
+ )
152
+ else:
153
+ return ValidationResult(
154
+ valid=False,
155
+ error=data.get("error", "Invalid API key"),
156
+ )
157
+ elif response.status_code == 401:
158
+ return ValidationResult(valid=False, error="Invalid API key")
159
+ else:
160
+ logger.warning(
161
+ "API key validation returned status %d for key %s",
162
+ response.status_code,
163
+ redacted_key,
164
+ )
165
+ # Fail closed but don't cache (transient service error)
166
+ return ValidationResult(
167
+ valid=False,
168
+ error=f"Auth service error (status {response.status_code})",
169
+ cacheable=False,
170
+ )
171
+
172
+ except httpx.TimeoutException:
173
+ if attempt < self.MAX_RETRIES:
174
+ logger.debug(
175
+ "API key validation timeout for key %s, retrying...",
176
+ redacted_key,
177
+ )
178
+ await asyncio.sleep(0.1 * (attempt + 1))
179
+ continue
180
+ logger.warning(
181
+ "API key validation timeout for key %s after %d attempts",
182
+ redacted_key,
183
+ attempt + 1,
184
+ )
185
+ return ValidationResult(
186
+ valid=False,
187
+ error="Auth service timeout",
188
+ cacheable=False,
189
+ )
190
+ except httpx.RequestError as exc:
191
+ if attempt < self.MAX_RETRIES:
192
+ logger.debug(
193
+ "API key validation request error for key %s: %s, retrying...",
194
+ redacted_key,
195
+ exc,
196
+ )
197
+ await asyncio.sleep(0.1 * (attempt + 1))
198
+ continue
199
+ logger.warning(
200
+ "API key validation request error for key %s: %s",
201
+ redacted_key,
202
+ exc,
203
+ )
204
+ return ValidationResult(
205
+ valid=False,
206
+ error="Auth service unavailable",
207
+ cacheable=False,
208
+ )
209
+ except Exception as exc:
210
+ logger.error(
211
+ "Unexpected error validating API key %s: %s",
212
+ redacted_key,
213
+ exc,
214
+ )
215
+ return ValidationResult(
216
+ valid=False,
217
+ error="Auth service error",
218
+ cacheable=False,
219
+ )
220
+
221
+ # Should not reach here, but fail closed
222
+ return ValidationResult(valid=False, error="Auth service error", cacheable=False)
223
+
224
+ async def invalidate_cache(self, api_key: str) -> None:
225
+ """Remove an API key from the cache."""
226
+ async with self._cache_lock:
227
+ self._cache.pop(api_key, None)
228
+
229
+ async def clear_cache(self) -> None:
230
+ """Clear all cached validations."""
231
+ async with self._cache_lock:
232
+ self._cache.clear()
233
+
234
+
235
+ __all__ = ["ApiKeyService", "ValidationResult"]
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
2
  from fastmcp import Context
3
3
 
4
4
  from models import MCPResponse
5
+ from models.unity_response import parse_resource_response
5
6
  from services.registry import mcp_for_unity_resource
6
7
  from services.tools import get_unity_instance_from_context
7
8
  from transport.unity_transport import send_with_unity_instance
@@ -44,4 +45,4 @@ async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
44
45
  "get_active_tool",
45
46
  {}
46
47
  )
47
- return ActiveToolResponse(**response) if isinstance(response, dict) else response
48
+ return parse_resource_response(response, ActiveToolResponse)
@@ -5,12 +5,14 @@ from typing import Any
5
5
  from fastmcp import Context
6
6
  from pydantic import BaseModel
7
7
 
8
+ from core.config import config
8
9
  from models import MCPResponse
9
10
  from services.registry import mcp_for_unity_resource
10
11
  from services.tools import get_unity_instance_from_context
11
12
  from services.state.external_changes_scanner import external_changes_scanner
12
13
  import transport.unity_transport as unity_transport
13
14
  from transport.legacy.unity_connection import async_send_command_with_retry
15
+ from transport.plugin_hub import PluginHub
14
16
 
15
17
 
16
18
  class EditorStateUnity(BaseModel):
@@ -132,17 +134,15 @@ async def infer_single_instance_id(ctx: Context) -> str | None:
132
134
  """
133
135
  await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
134
136
 
135
- try:
136
- transport = unity_transport._current_transport()
137
- except Exception:
138
- transport = None
137
+ transport = (config.transport_mode or "stdio").lower()
139
138
 
140
139
  if transport == "http":
141
140
  # HTTP/WebSocket transport: derive from PluginHub sessions.
142
141
  try:
143
- from transport.plugin_hub import PluginHub
144
-
145
- sessions_data = await PluginHub.get_sessions()
142
+ # In remote-hosted mode, filter sessions by user_id
143
+ user_id = ctx.get_state(
144
+ "user_id") if config.http_remote_hosted else None
145
+ sessions_data = await PluginHub.get_sessions(user_id=user_id)
146
146
  sessions = sessions_data.sessions if hasattr(
147
147
  sessions_data, "sessions") else {}
148
148
  if isinstance(sessions, dict) and len(sessions) == 1:
@@ -1,6 +1,7 @@
1
1
  from fastmcp import Context
2
2
 
3
3
  from models import MCPResponse
4
+ from models.unity_response import parse_resource_response
4
5
  from services.registry import mcp_for_unity_resource
5
6
  from services.tools import get_unity_instance_from_context
6
7
  from transport.unity_transport import send_with_unity_instance
@@ -26,4 +27,4 @@ async def get_layers(ctx: Context) -> LayersResponse | MCPResponse:
26
27
  "get_layers",
27
28
  {}
28
29
  )
29
- return LayersResponse(**response) if isinstance(response, dict) else response
30
+ return parse_resource_response(response, LayersResponse)
@@ -1,6 +1,7 @@
1
1
  from fastmcp import Context
2
2
 
3
3
  from models import MCPResponse
4
+ from models.unity_response import parse_resource_response
4
5
  from services.registry import mcp_for_unity_resource
5
6
  from services.tools import get_unity_instance_from_context
6
7
  from transport.unity_transport import send_with_unity_instance
@@ -31,4 +32,4 @@ async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse:
31
32
  "get_menu_items",
32
33
  params,
33
34
  )
34
- return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
35
+ return parse_resource_response(response, GetMenuItemsResponse)
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
2
  from fastmcp import Context
3
3
 
4
4
  from models import MCPResponse
5
+ from models.unity_response import parse_resource_response
5
6
  from services.registry import mcp_for_unity_resource
6
7
  from services.tools import get_unity_instance_from_context
7
8
  from transport.unity_transport import send_with_unity_instance
@@ -36,4 +37,4 @@ async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse:
36
37
  "get_prefab_stage",
37
38
  {}
38
39
  )
39
- return PrefabStageResponse(**response) if isinstance(response, dict) else response
40
+ return parse_resource_response(response, PrefabStageResponse)
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
2
  from fastmcp import Context
3
3
 
4
4
  from models import MCPResponse
5
+ from models.unity_response import parse_resource_response
5
6
  from services.registry import mcp_for_unity_resource
6
7
  from services.tools import get_unity_instance_from_context
7
8
  from transport.unity_transport import send_with_unity_instance
@@ -36,4 +37,4 @@ async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse:
36
37
  "get_project_info",
37
38
  {}
38
39
  )
39
- return ProjectInfoResponse(**response) if isinstance(response, dict) else response
40
+ return parse_resource_response(response, ProjectInfoResponse)
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
2
  from fastmcp import Context
3
3
 
4
4
  from models import MCPResponse
5
+ from models.unity_response import parse_resource_response
5
6
  from services.registry import mcp_for_unity_resource
6
7
  from services.tools import get_unity_instance_from_context
7
8
  from transport.unity_transport import send_with_unity_instance
@@ -52,4 +53,4 @@ async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse:
52
53
  "get_selection",
53
54
  {}
54
55
  )
55
- return SelectionResponse(**response) if isinstance(response, dict) else response
56
+ return parse_resource_response(response, SelectionResponse)