deepcell-cli 0.6.1__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 (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,906 @@
1
+ """MCP server exposing the DeepCell CLI as a single tool.
2
+
3
+ Supports two transports:
4
+ - **stdio** (default): local Claude Code / Desktop — no authentication.
5
+ - **streamable-http**: remote access for Claude.ai, ChatGPT, Manus —
6
+ OAuth 2.1 + PKCE when ``MCP_OAUTH_URL`` is set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import shlex
14
+ import traceback
15
+ from urllib.parse import quote, urlparse
16
+
17
+ # Suppress httpx INFO-level request logs so they don't leak into MCP output.
18
+ logging.getLogger("httpx").setLevel(logging.WARNING)
19
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ from click.testing import CliRunner
24
+ from mcp.server.fastmcp import FastMCP
25
+
26
+ from deepcell_cli.capabilities import (
27
+ MCP_ALLOWED_COMMANDS,
28
+ MCP_BLOCKED_COMMANDS as MCP_BLOCK_POLICY,
29
+ blocked_mcp_commands,
30
+ mcp_blocked_summary,
31
+ )
32
+
33
+ # ── Configuration ──────────────────────────────────────────
34
+
35
+ # Commands that require interactive input, produce binary output, need local
36
+ # sync state, or would mutate process-global credentials on a shared host.
37
+ BLOCKED_COMMANDS = blocked_mcp_commands()
38
+
39
+ # Commands that need confirmation flags auto-appended for non-interactive use.
40
+ # Keys are matched against the leading tokens, so a key may name a top-level
41
+ # command ("rm") or a "<group> <subcommand>" pair.
42
+ AUTO_CONFIRM: dict[str, list[str]] = {
43
+ "rm": ["-y"],
44
+ "restore": ["--yes"],
45
+ "changes revert": ["--yes"],
46
+ }
47
+
48
+ # Top-level commands documented in TOOL_DESCRIPTION. Must stay equal to the
49
+ # registered Click commands minus BLOCKED_COMMANDS — tests/test_mcp_server.py
50
+ # cross-checks both directions so the catalog can't silently drift.
51
+ DOCUMENTED_COMMANDS = frozenset(MCP_ALLOWED_COMMANDS)
52
+
53
+ TOOL_DESCRIPTION = """\
54
+ Run a DeepCell CLI command. Structured results are JSON; `cat` prints raw XML
55
+ and sheet queries print markdown. `guide <topic>` returns a JSON envelope whose
56
+ `content` is the topic's markdown, alongside the routing metadata (stage, order,
57
+ reads, commands, see_also) — read `content`. An "[exit code N]" line means the
58
+ command reported a problem — but NOT always that nothing happened: `write`,
59
+ `push`, `commit`, `replace` and the reasoning writers save
60
+ first and validate after, so exit 1 there means "saved AND invalid" (fix and
61
+ write again; do not retry blindly), while `defs` is whole-batch atomic and
62
+ exit 1 means nothing changed. Run `ref exit` for the full contract.
63
+
64
+ A .deepcell file connects selectable Reasoning, Spreadsheet, Document and Deck
65
+ surfaces; qualitative work need not invent a Spreadsheet. Use `help` for exact
66
+ invocation and exits, `rules` for invariants, `guide` for procedures, `ref` for
67
+ legal names and values, and `example` for complete valid artifacts.
68
+
69
+ Available commands:
70
+
71
+ ORIENT — look at what exists before adding to it
72
+ ls List files in active project
73
+ cat <file> [--revision R] Print file content (XML)
74
+ describe <file> Inspect model schema (items, contexts, sheets)
75
+ query <file> <item> [ctx] [status]
76
+ Query a value or all values for an item
77
+ query <file> --sheet <id> Render a sheet as markdown
78
+ cell-meta <file> <item> <ctx> [--status REF] [--scenario REF] [--custom-dimensions dim:member;...]
79
+ Show cell metadata (formula, deps, data source)
80
+ grep <pattern> [--file F] Search items/values by regex
81
+
82
+ INGEST — external data in (skip for from-scratch work)
83
+ import <file.xlsx|csv> ... Import xlsx/csv into a .deepcell document
84
+ ingest cn ... Ingest cninfo A-share filings
85
+
86
+ GENERATE — create one file, then build only the surfaces the work needs
87
+ write <file> --content <xml> Upload/create a file (inline content)
88
+ write <file> --content-base64 <b64> Upload/create (base64-encoded content)
89
+ write <file> --file <path> Upload/create from a LOCAL file (see caveat)
90
+ edit <file> <item> <ctx> <value> [--force]
91
+ Edit a single value
92
+ edit <file> --batch '<json>'
93
+ Batch edit: inline JSON array (starts with
94
+ '[' or '{'), or a JSON file path
95
+ defs add-item / update-item / delete-item / rename-item / reorder-item
96
+ defs add-calc / update-calc / delete-calc Formulas (CalcDefs)
97
+ defs add-context / update-context / delete-context / rename-context
98
+ defs add-period / add-scenario / add-status / add-rule / delete-rule
99
+ defs add-sheet / rename-sheet / reorder-sheets / delete-sheet
100
+ defs add-block / set-block-attrs / add-axis-member / delete-axis-member
101
+ defs add-format / update-format / delete-format / set-format / header
102
+ defs add-source / update-source / set-source-cites / delete-source
103
+ defs add-doc / update-doc / delete-doc
104
+ defs reorder-contexts / rename-dimension / rename-member
105
+ defs add-sensitivity / update-sensitivity
106
+ defs apply <file> --ops '<json>'
107
+ Apply a JSON ops batch atomically (inline JSON;
108
+ --ops-file reads a LOCAL path — see caveat)
109
+ (defs ops are validated; prefer them over replace.
110
+ Run `defs --help` for every convenience command.)
111
+ reasoning add-claim / add-assumption / add-evidence / add-argument
112
+ reasoning update-claim / update-assumption Patch attributes in place
113
+ reasoning graph / impact / lint Inspect the reasoning graph
114
+ doc list <file> Every <Document> in the file
115
+ doc show <file> [--doc ID] [--as text|markdown]
116
+ The memo, with every deepcell: link RESOLVED
117
+ doc outline <file> Headings and the anchor each is addressable by
118
+ doc links <file> [--unresolved] Every reference and whether it resolved
119
+ doc backlinks <file> --target 'claim/t_hold'
120
+ What cites a reference -- THIS FILE ONLY
121
+ deck add <file> --name "Q3 review" Add a deck
122
+ deck add-slide <file> --deck d1 --name "Outlook"
123
+ Add an empty slide
124
+ deck rename / reorder / delete Deck structure
125
+ deck rename-slide / reorder-slide / delete-slide
126
+ Slide structure (all need --deck)
127
+ deck bind <file> --deck d1 --binding-id b1 --binding-kind value --ref ...
128
+ Point a slide at a live value, or update it
129
+ deck unbind <file> <binding_id> --deck d1
130
+ Deck structure is flags, above.
131
+ Only two ops still need `defs apply` — "set_presentation_slide_html" and
132
+ "set_presentation_deck_style", which carry document-sized arguments.
133
+ Exact op names and fields come from `ref op`. See "guide present/decks".
134
+
135
+ REVISE — change a premise and reassess what depends on it
136
+ replace <file> <old> <new>
137
+ String-replace in XML (last resort — prefer defs;
138
+ `edit --replace` is a deprecated alias)
139
+ variant list List variants
140
+ variant create <name> Create a variant
141
+ (Note: variant checkout/diff/merge need a LOCAL clone — blocked here)
142
+ rm <file> Delete a file (auto-confirmed)
143
+ log [--file F] [-n N] Show version history
144
+ diff <rev_a> [rev_b] Diff between revisions
145
+ restore <rev> [--file F] Restore to a revision (auto-confirmed)
146
+ changes list [FILE] [-n N] Grouped changes by writer (agent/CLI/browser)
147
+ changes diff <base> [head] What one change did, semantically
148
+ changes revert <base> <head>
149
+ Undo one change as a new commit (auto-confirmed)
150
+
151
+ VERIFY — change one thing, see everything it affects
152
+ relationships <file> Show relationship graph
153
+ reasoning-diff <file> Warn if a Claim's anchor changed vs git HEAD
154
+ impact show <file> --since <rev> Every place a change may have made stale
155
+ (spreadsheet -> reasoning -> document -> deck)
156
+ impact review <file> <key> --surface S --locator L
157
+ Mark one place reviewed (the marker disappears)
158
+ impact apply <file> <key> --op O --target-id T --surface S --locator L
159
+ Apply one reviewed reasoning fix, recorded as revised
160
+ impact reopen <file> <key> Undo one review decision
161
+ claim falsified / history / variant Claim lifecycle in a project file
162
+ assumption impact <file> <id> Direct dependent Claims (project file)
163
+
164
+ PRESENT — deliver the work to someone else
165
+ viewer <file> Print the signed-in workbench URL (auth: edit, chat)
166
+ share create <file> [--permission view|edit] [--expires-days N]
167
+ Create a browser share link (prints viewer URL)
168
+ share list [--file F] List active share links
169
+ share revoke <id> Revoke a share link
170
+ After writing or editing a .deepcell file, offer the user a browser link.
171
+ "viewer <file>" prints the owner's signed-in workbench URL, while
172
+ "share create <file>" prints a public view URL that opens for anyone
173
+ (including readers who cannot use the workbench). See "guide present/deliver".
174
+
175
+ SESSION — who you are and where your work lands
176
+ whoami Show the authenticated user
177
+ project list List projects
178
+ project use <slug> Set active project
179
+ project create <name> Create a new project
180
+ project info [slug] Show project details
181
+ doctor Check setup and report what to run next
182
+
183
+ SYNC — local <-> cloud
184
+ download <file> -o <path> Download file to local path
185
+ (the rest of sync is blocked here — see below)
186
+
187
+ LEARN — the five reference surfaces
188
+ help [command] Every command, flag, exit code and example.
189
+ "help -f json" is the whole tree in one call.
190
+ guide [topic] Show built-in topic guides
191
+ guide --capabilities Jingwei/CLI/reference/transport contract
192
+ rules [id] [--surfaces LIST | --all]
193
+ Invariants, scoped to the work shape.
194
+ "rules" alone is the universal set only.
195
+ "--surfaces reasoning,document" adds a shape's.
196
+ "--all" is every rule, whatever the shape.
197
+ Lint findings cite the rule they enforce, so
198
+ "rules R2" reads the rule a failure just named.
199
+ ref [ns|ns/name|id] Legal values, generated from the code.
200
+ Run bare `ref` for the generated namespaces.
201
+ Resolves any typed id: "ref rule:R2".
202
+ example [--pack NAME] Complete, valid documents, indexed by the
203
+ Mechanic each one demonstrates.
204
+ example show <name> <layer> One layer: skeleton, full, or transcript.
205
+ Transcript is the build order — the thing
206
+ Agents most often get wrong.
207
+ example get <name> --into F Write a guaranteed-parsing document to F.
208
+ ref search <text> Search every surface at once.
209
+ Each result says its type: op, topic, rule.
210
+
211
+ Local-disk caveat: these read or write paths on the machine running this server,
212
+ not project files, so over a remote connection they cannot see your model:
213
+ import, download, reasoning-diff, write --file, and defs apply --ops-file.
214
+ Workspace-backed claim, assumption, and reasoning reads are not local-disk
215
+ commands and work normally over MCP. Prefer inline forms where offered:
216
+ write --content and defs apply --ops.
217
+
218
+ "--stdin" is unavailable here (this server runs the CLI with no stdin, so it
219
+ would write empty content). Use write --content or --content-base64.
220
+
221
+ "restore <rev>" without --file rolls back the WHOLE project and is
222
+ auto-confirmed — pass --file to limit it, and check `log` first.
223
+
224
+ Blocked commands: {blocked_commands}.
225
+
226
+ Examples:
227
+ ls
228
+ query model.deepcell Revenue FY2025E projected
229
+ query model.deepcell --sheet income_statement
230
+ edit model.deepcell Revenue_Growth FY2025E 0.12 --force
231
+ edit model.deepcell --batch '[{"itemRef":"Revenue","contextRef":"FY2025E","newValue":"120"}]'
232
+ grep Revenue --file model.deepcell
233
+ describe model.deepcell
234
+ project list
235
+ """.replace("{blocked_commands}", mcp_blocked_summary())
236
+
237
+
238
+ # ── Command parsing ────────────────────────────────────────
239
+
240
+
241
+ # Root-level options that take a value. Their *values* must not be mistaken
242
+ # for the subcommand name when locating it (mirrors `FlexibleGroup`).
243
+ _GLOBAL_VALUE_OPTS = frozenset({"-f", "--format", "--project", "--workspace"})
244
+
245
+
246
+ def _command_index(tokens: list[str]) -> int | None:
247
+ """Index of the subcommand name, skipping leading global options.
248
+
249
+ ``FlexibleGroup`` accepts global options before the subcommand, so
250
+ ``-v push`` really does run ``push``. Keying the safety checks off
251
+ ``tokens[0]`` would leave every blocked command one option away from
252
+ executing, and every auto-confirm flag one option away from being
253
+ dropped.
254
+ """
255
+ i = 0
256
+ while i < len(tokens):
257
+ tok = tokens[i]
258
+ if tok in _GLOBAL_VALUE_OPTS:
259
+ i += 2 # skip the option and its value
260
+ continue
261
+ if tok.startswith("-"):
262
+ i += 1
263
+ continue
264
+ return i
265
+ return None
266
+
267
+
268
+ def _command_path(tokens: list[str]) -> list[str]:
269
+ """The non-option tokens, in order, with option values skipped.
270
+
271
+ ``_command_index`` finds only the *first* command word. Matching a
272
+ ``"<group> <subcommand>"`` key needs the same option-skipping applied all
273
+ the way down.
274
+ """
275
+ path: list[str] = []
276
+ i = 0
277
+ while i < len(tokens):
278
+ tok = tokens[i]
279
+ if tok in _GLOBAL_VALUE_OPTS:
280
+ i += 2 # skip the option and its value
281
+ continue
282
+ if tok.startswith("-"):
283
+ i += 1
284
+ continue
285
+ path.append(tok)
286
+ i += 1
287
+ return path
288
+
289
+
290
+ def _parse_command(command: str) -> list[str]:
291
+ """Parse a CLI command string into args for Click, with safety checks."""
292
+ tokens = shlex.split(command)
293
+
294
+ # Strip optional leading "deepcell" prefix
295
+ if tokens and tokens[0] == "deepcell":
296
+ tokens = tokens[1:]
297
+
298
+ if not tokens:
299
+ # Plain, and not by omission: `--help` is an eager Click flag. It fires
300
+ # during parsing and exits before `--format` is ever applied, so
301
+ # `--help --format json` returns text — the `--format json` that used
302
+ # to sit here did nothing at all. Every other path below prepends it
303
+ # for real (`test_format_json_always_prepended`), which is exactly what
304
+ # made the dead one read as if this path were JSON too.
305
+ #
306
+ # Root `--help` is also not the thing the next comment is about: it is
307
+ # Click's ~90-line root help, which names `help` and `guide` as the two
308
+ # entry points. That is a good first MCP response.
309
+ return ["--help"]
310
+
311
+ # Root JSON help is the complete generated command/capability tree (about
312
+ # 480 KB at the time this guard was added). That is useful as a local,
313
+ # cacheable artifact, but it is an unusable first MCP response and can
314
+ # exhaust a client's tool-result budget before the agent reads one command.
315
+ # Keep command-specific help structured; only the bare index is the compact
316
+ # plain listing. An explicit ``help -f json`` remains available.
317
+ if tokens == ["help"]:
318
+ return ["--format", "plain", "help"]
319
+
320
+ cmd_idx = _command_index(tokens)
321
+ cmd_name = tokens[cmd_idx] if cmd_idx is not None else tokens[0]
322
+
323
+ if cmd_name in BLOCKED_COMMANDS:
324
+ policy = MCP_BLOCK_POLICY[cmd_name]
325
+ raise ValueError(
326
+ f"Command '{cmd_name}' is blocked in MCP mode "
327
+ f"({policy['reason']}: {policy['detail']}). "
328
+ f"Blocked: {', '.join(sorted(BLOCKED_COMMANDS))}"
329
+ )
330
+
331
+ # This server runs the CLI through CliRunner, whose stdin is empty, so
332
+ # `--stdin` reads "" and overwrites the file with empty content — a
333
+ # destructive write the caller reads back as a malformed-XML error.
334
+ if "--stdin" in tokens:
335
+ raise ValueError(
336
+ "'--stdin' cannot be used over MCP (this server runs the CLI with "
337
+ "no stdin, so it would write empty content). Pass the content "
338
+ "inline with --content '<xml/>' or --content-base64 instead."
339
+ )
340
+
341
+ # Auto-append confirmation flags for destructive commands. Keys may be a
342
+ # top-level command ("rm") or a "<group> <subcommand>" pair — matched
343
+ # against the resolved command path after options have been skipped.
344
+ path = _command_path(tokens)
345
+ for key, flags in AUTO_CONFIRM.items():
346
+ key_tokens = key.split()
347
+ if path[: len(key_tokens)] == key_tokens:
348
+ for flag in flags:
349
+ if flag not in tokens:
350
+ tokens.append(flag)
351
+
352
+ # Always force JSON output for structured agent consumption
353
+ return ["--format", "json"] + tokens
354
+
355
+
356
+ def _token_identity(token: str) -> str:
357
+ """Stable per-caller key: the JWT's subject, else the token itself.
358
+
359
+ Keying on the raw access token meant the path changed every time the token
360
+ refreshed, silently resetting the caller's ``project use`` mid-session.
361
+ The payload is decoded WITHOUT verification on purpose — this only picks a
362
+ filename, never authorizes anything, and the server validates the token.
363
+ """
364
+ import base64
365
+ import json as _json
366
+
367
+ parts = token.split(".")
368
+ if len(parts) == 3:
369
+ try:
370
+ payload = parts[1]
371
+ payload += "=" * (-len(payload) % 4) # restore base64url padding
372
+ claims = _json.loads(base64.urlsafe_b64decode(payload))
373
+ sub = claims.get("sub")
374
+ if sub:
375
+ return str(sub)
376
+ except Exception:
377
+ pass # not a JWT we can read — fall back to the opaque token
378
+ return token
379
+
380
+
381
+ def _config_path_for_token(token: str) -> str:
382
+ """Per-caller config path, keyed by a hash of the caller's identity.
383
+
384
+ Hashed so no credential lands in a filename. The file is seeded from the
385
+ base config's ``api_url`` on creation: ``get_api_url()`` falls back to
386
+ DEFAULT_API_URL (the public beta) when the config it reads has no
387
+ ``api_url``, so handing a caller an empty config silently repointed a
388
+ self-hosted deployment at beta.deepcell.net. ``active_workspace`` is
389
+ deliberately NOT inherited — isolating it is the whole point of this file.
390
+ """
391
+ import hashlib
392
+ import json as _json
393
+ from pathlib import Path
394
+
395
+ digest = hashlib.sha256(
396
+ _token_identity(token).encode("utf-8")
397
+ ).hexdigest()[:16]
398
+ path = Path.home() / ".deepcell" / "mcp-sessions" / f"{digest}.json"
399
+
400
+ if not path.exists():
401
+ seed: dict = {}
402
+ base = Path.home() / ".deepcell" / "config.json"
403
+ try:
404
+ if base.exists():
405
+ base_cfg = _json.loads(base.read_text())
406
+ if isinstance(base_cfg, dict) and base_cfg.get("api_url"):
407
+ seed["api_url"] = base_cfg["api_url"]
408
+ except Exception:
409
+ pass # unreadable base config — the env vars still apply
410
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
411
+ path.write_text(_json.dumps(seed, indent=2) + "\n")
412
+ try:
413
+ path.chmod(0o600)
414
+ except OSError:
415
+ pass
416
+
417
+ return str(path)
418
+
419
+
420
+ def _stream(result, name: str) -> str:
421
+ """Read ``result.stdout`` / ``result.stderr``, tolerating old Click.
422
+
423
+ On Click 8.1 with a mixed runner, ``.stderr`` raises ``ValueError``; fall
424
+ back to the combined stream for stdout and to empty for stderr.
425
+ """
426
+ try:
427
+ return getattr(result, name) or ""
428
+ except ValueError:
429
+ return (result.output or "") if name == "stdout" else ""
430
+
431
+
432
+ def _run_cli(
433
+ args: list[str],
434
+ env_override: dict[str, str] | None = None,
435
+ ) -> tuple[str, str, int]:
436
+ """Invoke the Click CLI and capture output."""
437
+ from deepcell_cli.config import ENV_CLIENT as _ENV_CLIENT
438
+ from deepcell_cli.main import cli
439
+ from deepcell_cli.upgrade_check import ENV_DISABLE as _NO_UPGRADE_CHECK
440
+
441
+ # Every stderr byte a command writes is appended to the MCP response as
442
+ # "[stderr] ...", so an upgrade-available notice would ride along on every
443
+ # single tool result. It is also advice the caller cannot act on — the
444
+ # install being checked is this server's. Silence it for all MCP paths,
445
+ # not just the OAuth one, since env_override is None without a token.
446
+ #
447
+ # `DEEPCELL_CLIENT` rides along for the same reason: this function runs the
448
+ # CLI *in-process*, so without it every MCP tool call reaches the API as an
449
+ # ordinary CLI request and the two are indistinguishable in the funnel —
450
+ # yet an agent calling a tool and a human typing a command are different
451
+ # products with different questions to ask of them. Set after the spread so
452
+ # a caller cannot claim to be anything else.
453
+ env_override = {
454
+ **(env_override or {}),
455
+ _NO_UPGRADE_CHECK: "1",
456
+ _ENV_CLIENT: "mcp",
457
+ }
458
+
459
+ try:
460
+ runner = CliRunner(mix_stderr=False)
461
+ except TypeError:
462
+ # Click 8.2+ removed mix_stderr
463
+ runner = CliRunner()
464
+
465
+ result = runner.invoke(cli, args, catch_exceptions=True, env=env_override)
466
+
467
+ # `Result.output` is the INTERLEAVED stdout+stderr stream on Click 8.2+
468
+ # ("what the user would see in a terminal"). Using it as stdout splices
469
+ # warnings into the JSON payload and repeats them under `[stderr]`.
470
+ # `Result.stdout` is the clean stream on both 8.1 (mix_stderr=False) and
471
+ # 8.2+.
472
+ stdout = _stream(result, "stdout")
473
+ stderr = _stream(result, "stderr")
474
+ exit_code = result.exit_code
475
+
476
+ # Surface unexpected exceptions
477
+ if result.exception and not isinstance(result.exception, SystemExit):
478
+ tb = "".join(traceback.format_exception(type(result.exception), result.exception, result.exception.__traceback__))
479
+ stderr = f"{stderr}\n{tb}".strip()
480
+ if exit_code == 0:
481
+ exit_code = 1
482
+
483
+ return stdout, stderr, exit_code
484
+
485
+
486
+ # ── MCP Server ─────────────────────────────────────────────
487
+
488
+ def _build_server(
489
+ host: str = "127.0.0.1",
490
+ port: int = 8080,
491
+ oauth_url: str | None = None,
492
+ ) -> FastMCP:
493
+ """Build the FastMCP server instance.
494
+
495
+ When *oauth_url* is provided, OAuth 2.1 + PKCE authentication is enabled
496
+ for the streamable-http transport (required for Claude.ai / ChatGPT / Manus).
497
+ """
498
+ kwargs: dict = dict(
499
+ host=host,
500
+ port=port,
501
+ instructions=(
502
+ "DeepCell CLI server. Use the 'deepcell' tool to run CLI commands "
503
+ "against .deepcell financial models. Output is JSON, except the "
504
+ "help index — an empty command and a bare `help` return plain "
505
+ "text, because the JSON command tree is ~550 KB and would exhaust "
506
+ "a client's result budget before it read one command. Ask for "
507
+ "`help -f json` explicitly if you want that tree."
508
+ ),
509
+ )
510
+
511
+ if oauth_url:
512
+ from urllib.parse import urlparse
513
+
514
+ from mcp.server.auth.settings import (
515
+ AuthSettings,
516
+ ClientRegistrationOptions,
517
+ RevocationOptions,
518
+ )
519
+ from deepcell_cli.oauth_provider import DeepCellOAuthProvider
520
+
521
+ jwt_secret = os.environ.get("JWT_SECRET", "")
522
+ if not jwt_secret:
523
+ raise RuntimeError(
524
+ "JWT_SECRET env var is required when MCP_OAUTH_URL is set. "
525
+ "It must match the Jingwei API's JWT_SECRET."
526
+ )
527
+
528
+ # MCP_OAUTH_URL may include the MCP path (e.g. https://example.com/mcp).
529
+ # Split into base URL (for OAuth issuer / login redirects) and resource
530
+ # URL (the actual MCP transport endpoint).
531
+ parsed = urlparse(oauth_url)
532
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
533
+ resource_url = oauth_url # full URL including /mcp path
534
+
535
+ provider = DeepCellOAuthProvider(
536
+ api_url=os.environ.get("DEEPCELL_API_URL", "http://localhost:8001"),
537
+ server_url=base_url,
538
+ # The consent page is a frontend route. In the standard deployment
539
+ # nginx serves it from this same origin, so base_url is the right
540
+ # default; MCP_FRONTEND_URL overrides for split-origin setups.
541
+ frontend_url=os.environ.get("MCP_FRONTEND_URL") or base_url,
542
+ jwt_secret=jwt_secret,
543
+ jwt_algorithm=os.environ.get("JWT_ALGORITHM", "HS256"),
544
+ jwt_issuer=os.environ.get("JWT_ISSUER", "deepcell-api"),
545
+ jwt_audience=os.environ.get("JWT_AUDIENCE", "deepcell-user"),
546
+ )
547
+
548
+ kwargs["auth_server_provider"] = provider
549
+ kwargs["auth"] = AuthSettings(
550
+ issuer_url=base_url,
551
+ resource_server_url=resource_url,
552
+ client_registration_options=ClientRegistrationOptions(
553
+ enabled=True,
554
+ valid_scopes=["deepcell"],
555
+ default_scopes=["deepcell"],
556
+ ),
557
+ revocation_options=RevocationOptions(enabled=True),
558
+ required_scopes=["deepcell"],
559
+ )
560
+
561
+ return FastMCP("deepcell", **kwargs)
562
+
563
+
564
+ server = _build_server()
565
+
566
+
567
+ def _get_access_token_for_request() -> str | None:
568
+ """Extract the Bearer token from the MCP auth context (if available)."""
569
+ try:
570
+ from mcp.server.auth.middleware.auth_context import get_access_token
571
+
572
+ at = get_access_token()
573
+ return at.token if at else None
574
+ except Exception:
575
+ return None
576
+
577
+
578
+ @server.tool(description=TOOL_DESCRIPTION)
579
+ def deepcell(command: str) -> str:
580
+ """Run a DeepCell CLI command."""
581
+ try:
582
+ args = _parse_command(command)
583
+ except ValueError as exc:
584
+ # Carry the same "[exit code N]" marker a failing command would.
585
+ # `ref exit` tells agents to match on that prefix rather than
586
+ # on the wording, so a refusal that returned bare prose read as a
587
+ # command that ran and succeeded.
588
+ return (
589
+ f"[exit code 1] Command refused before it ran — nothing changed. "
590
+ f"{exc}"
591
+ )
592
+
593
+ # If running behind OAuth, propagate the authenticated user's token and
594
+ # give them a private config file. `project use` (and the single-
595
+ # workspace auto-select) persist the active workspace to disk, and on a
596
+ # shared remote host that file is process-global — one caller's active
597
+ # workspace would silently become every other caller's default.
598
+ env_override: dict[str, str] | None = None
599
+ token = _get_access_token_for_request()
600
+ if token:
601
+ env_override = {
602
+ "DEEPCELL_ACCESS_TOKEN": token,
603
+ "DEEPCELL_CONFIG": _config_path_for_token(token),
604
+ }
605
+
606
+ stdout, stderr, exit_code = _run_cli(args, env_override=env_override)
607
+
608
+ # Build response
609
+ parts: list[str] = []
610
+ if stdout.strip():
611
+ parts.append(stdout.strip())
612
+ if stderr.strip():
613
+ parts.append(f"[stderr] {stderr.strip()}")
614
+ if exit_code != 0:
615
+ # Always spell out failure — several commands exit 1 WITH output
616
+ # (e.g. "saved but invalid" validation errors), and the caller only
617
+ # sees this text, never the exit code itself.
618
+ parts.append(
619
+ f"[exit code {exit_code}] Command reported a problem. This does "
620
+ "NOT always mean nothing changed — writes save before they "
621
+ "validate, so check for a commit_sha/revision in the output "
622
+ "above before retrying. See `ref exit/1`."
623
+ )
624
+
625
+ return "\n".join(parts) if parts else "(no output)"
626
+
627
+
628
+ # ── OAuth consent routes ─────────────────────────────────────
629
+
630
+
631
+ def _add_oauth_routes(srv: FastMCP, provider: "DeepCellOAuthProvider") -> None:
632
+ """Mount the consent-flow JSON routes on the FastMCP Starlette app.
633
+
634
+ The sign-in UI itself is the frontend's ``/mcp-auth`` page, which renders
635
+ the same shared ``AuthPanel`` as every other DeepCell sign-in surface. Only
636
+ the OAuth state machine lives here, because ``_pending_auth`` is in-process.
637
+
638
+ These routes are NOT behind the MCP bearer auth middleware: the caller is a
639
+ browser mid-authorization that has no MCP token yet. ``/oauth/complete``
640
+ does its own check instead — it requires the consenting user's Jingwei
641
+ access token and refuses to issue a code unless the device code was approved
642
+ by *that same* user. Holding a `session_id` is therefore not enough to bind
643
+ an identity to someone else's pending authorization.
644
+ """
645
+ from starlette.requests import Request
646
+ from starlette.responses import JSONResponse, RedirectResponse, Response
647
+ from starlette.routing import Route
648
+
649
+ from mcp.server.auth.provider import construct_redirect_uri
650
+
651
+ _NO_STORE = {"Cache-Control": "no-store"}
652
+
653
+ # When the consent page is served from a different origin than this server
654
+ # (MCP_FRONTEND_URL), its fetches are cross-origin. Allow exactly that one
655
+ # origin, on exactly these routes — not app-wide, which would also loosen
656
+ # /mcp itself. Credentials are never allowed: the page authenticates with an
657
+ # explicit Authorization header, so no cookie ever needs to ride along.
658
+ _parsed_frontend = urlparse(provider.frontend_url)
659
+ _ALLOWED_ORIGIN = (
660
+ f"{_parsed_frontend.scheme}://{_parsed_frontend.netloc}"
661
+ if _parsed_frontend.scheme and _parsed_frontend.netloc
662
+ else ""
663
+ )
664
+
665
+ def _headers(request: Request) -> dict[str, str]:
666
+ headers = dict(_NO_STORE)
667
+ origin = request.headers.get("origin")
668
+ if origin and _ALLOWED_ORIGIN and origin == _ALLOWED_ORIGIN:
669
+ headers["Access-Control-Allow-Origin"] = origin
670
+ headers["Vary"] = "Origin"
671
+ return headers
672
+
673
+ def _json(request: Request, payload: dict, status_code: int = 200) -> JSONResponse:
674
+ return JSONResponse(payload, status_code=status_code, headers=_headers(request))
675
+
676
+ def _expired(request: Request) -> JSONResponse:
677
+ return _json(
678
+ request,
679
+ {
680
+ "error": "invalid_session",
681
+ "error_description": (
682
+ "Invalid or expired session. Restart the authorization "
683
+ "flow from your AI assistant."
684
+ ),
685
+ },
686
+ status_code=404,
687
+ )
688
+
689
+ def _bearer(request: Request) -> str:
690
+ auth = request.headers.get("authorization", "")
691
+ scheme, _, token = auth.partition(" ")
692
+ return token.strip() if scheme.lower() == "bearer" else ""
693
+
694
+ async def preflight(request: Request) -> Response:
695
+ """CORS preflight for the split-origin consent page.
696
+
697
+ Only fires when MCP_FRONTEND_URL puts the page on another origin; the
698
+ same-origin deployment never sends one.
699
+ """
700
+ headers = _headers(request)
701
+ if "Access-Control-Allow-Origin" in headers:
702
+ headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
703
+ headers["Access-Control-Allow-Headers"] = "authorization, content-type"
704
+ headers["Access-Control-Max-Age"] = "600"
705
+ return Response(status_code=204, headers=headers)
706
+
707
+ async def session_get(request: Request) -> Response:
708
+ """Consent-screen metadata: who is asking, and where the grant goes."""
709
+ session_id = request.query_params.get("session_id", "")
710
+ info = provider.describe_pending(session_id) if session_id else None
711
+ if info is None:
712
+ return _expired(request)
713
+ return _json(request, info)
714
+
715
+ async def complete_post(request: Request) -> Response:
716
+ """Finish the authorization once the user has consented.
717
+
718
+ Body: ``{session_id, device_code}``, plus the consenting user's Jingwei
719
+ access token as a Bearer header.
720
+
721
+ Both are required, and they must agree. The device code proves a signed-in
722
+ user approved *something*; the bearer proves who is sitting in front of
723
+ this browser. Without the cross-check, anyone holding a leaked
724
+ ``session_id`` could complete it with a device code approved on their own
725
+ account and silently bind the victim's connector to the attacker's
726
+ workspace — the redirect still carries the victim's ``state``, so the AI
727
+ platform would accept it.
728
+ """
729
+ try:
730
+ body = await request.json()
731
+ except ValueError:
732
+ return _json(
733
+ request,
734
+ {"error": "invalid_request", "error_description": "Malformed JSON body."},
735
+ status_code=400,
736
+ )
737
+ if not isinstance(body, dict):
738
+ return _json(
739
+ request,
740
+ {"error": "invalid_request", "error_description": "Expected a JSON object."},
741
+ status_code=400,
742
+ )
743
+
744
+ session_id = str(body.get("session_id") or "")
745
+ device_code = str(body.get("device_code") or "")
746
+ if not session_id or not device_code:
747
+ return _json(
748
+ request,
749
+ {
750
+ "error": "invalid_request",
751
+ "error_description": "session_id and device_code are required.",
752
+ },
753
+ status_code=400,
754
+ )
755
+
756
+ # Who is consenting? Verified locally against the shared JWT secret.
757
+ bearer = _bearer(request)
758
+ consenting = await provider.load_access_token(bearer) if bearer else None
759
+ if consenting is None:
760
+ return _json(
761
+ request,
762
+ {
763
+ "error": "invalid_token",
764
+ "error_description": "A valid DeepCell access token is required.",
765
+ },
766
+ status_code=401,
767
+ )
768
+
769
+ # Check the session BEFORE burning the single-use device code, so a
770
+ # stale tab doesn't consume a code it can't complete with.
771
+ if provider.describe_pending(session_id) is None:
772
+ return _expired(request)
773
+
774
+ redeemed = await provider.redeem_device_code(device_code)
775
+ if redeemed is None:
776
+ return _json(
777
+ request,
778
+ {
779
+ "error": "authorization_pending",
780
+ "error_description": (
781
+ "That authorization could not be confirmed. Please try again."
782
+ ),
783
+ },
784
+ status_code=400,
785
+ )
786
+ user_id, jingwei_refresh_token = redeemed
787
+
788
+ if user_id != consenting.user_id:
789
+ logger.warning(
790
+ "Rejected /oauth/complete: device code approved by a different user"
791
+ )
792
+ return _json(
793
+ request,
794
+ {
795
+ "error": "access_denied",
796
+ "error_description": (
797
+ "That approval belongs to a different account."
798
+ ),
799
+ },
800
+ status_code=403,
801
+ )
802
+
803
+ try:
804
+ code, redirect_uri, state = provider.create_authorization_code(
805
+ session_id, user_id, jingwei_refresh_token
806
+ )
807
+ except ValueError:
808
+ return _expired(request)
809
+
810
+ # Returned as JSON rather than a 302 so the page can navigate itself —
811
+ # fetch() would otherwise follow the redirect to the AI platform's
812
+ # callback and swallow it.
813
+ return _json(
814
+ request,
815
+ {"redirect_url": construct_redirect_uri(redirect_uri, code=code, state=state)},
816
+ )
817
+
818
+ async def login_redirect(request: Request) -> Response:
819
+ """Back-compat for the retired server-rendered password page.
820
+
821
+ This only buys a readable error, not a surviving authorization:
822
+ ``_pending_auth`` is in-process, so a deploy drops every pending session
823
+ and the consent page will report it expired. That still beats a bare 404
824
+ for anyone holding an old ``/oauth/login`` URL.
825
+ """
826
+ session_id = request.query_params.get("session_id", "")
827
+ target = f"{provider.frontend_url}/mcp-auth"
828
+ if session_id:
829
+ target = f"{target}?session_id={quote(session_id, safe='')}"
830
+ return RedirectResponse(url=target, status_code=302, headers=_NO_STORE)
831
+
832
+ # We need to inject these routes BEFORE the app is fully constructed.
833
+ # FastMCP uses _custom_starlette_routes for this purpose.
834
+ srv._custom_starlette_routes = getattr(srv, "_custom_starlette_routes", []) + [
835
+ Route("/oauth/session", endpoint=session_get, methods=["GET"]),
836
+ Route("/oauth/complete", endpoint=complete_post, methods=["POST"]),
837
+ Route("/oauth/login", endpoint=login_redirect, methods=["GET"]),
838
+ Route("/oauth/session", endpoint=preflight, methods=["OPTIONS"]),
839
+ Route("/oauth/complete", endpoint=preflight, methods=["OPTIONS"]),
840
+ ]
841
+
842
+
843
+ # ── Entry point ────────────────────────────────────────────
844
+
845
+
846
+ def main() -> None:
847
+ """Run the MCP server.
848
+
849
+ Usage:
850
+ deepcell-mcp # stdio (local, for Claude Code / Desktop)
851
+ deepcell-mcp --http # streamable-http on 127.0.0.1:8080
852
+ deepcell-mcp --http --port 9000 # custom port
853
+ deepcell-mcp --http --host 0.0.0.0 # all interfaces (for Claude connector)
854
+
855
+ Environment variables:
856
+ MCP_OAUTH_URL Public URL of this server (enables OAuth 2.1 for remote access)
857
+ JWT_SECRET Required when MCP_OAUTH_URL is set (must match Jingwei API)
858
+ MCP_FRONTEND_URL Origin serving the /mcp-auth consent page
859
+ (default: the MCP_OAUTH_URL origin)
860
+ MCP_HOST Default host for --http (default: 127.0.0.1)
861
+ MCP_PORT Default port for --http (default: 8080)
862
+ LOG_LEVEL Root log level (default: INFO)
863
+ LOG_FORMAT text (default) or json, for GCP Cloud Logging ingest
864
+ LOG_SERVICE Value of the `service` label in json output
865
+ """
866
+ import sys
867
+
868
+ from deepcell_cli.logging_setup import setup_logging
869
+
870
+ # Until now this process installed no handler at all, so its records fell
871
+ # through to logging's lastResort — stderr, WARNING and above, unformatted.
872
+ # Everything below WARNING was simply discarded.
873
+ setup_logging()
874
+
875
+ if "--http" in sys.argv:
876
+ host = os.environ.get("MCP_HOST", "127.0.0.1")
877
+ port = int(os.environ.get("MCP_PORT", "8080"))
878
+ oauth_url = os.environ.get("MCP_OAUTH_URL")
879
+
880
+ # Parse --host / --port from argv
881
+ args = sys.argv[1:]
882
+ for i, arg in enumerate(args):
883
+ if arg == "--host" and i + 1 < len(args):
884
+ host = args[i + 1]
885
+ elif arg == "--port" and i + 1 < len(args):
886
+ port = int(args[i + 1])
887
+
888
+ srv = _build_server(host=host, port=port, oauth_url=oauth_url)
889
+ # Re-register the tool on this new instance
890
+ srv.tool(description=TOOL_DESCRIPTION)(deepcell)
891
+
892
+ # Add OAuth login routes when auth is enabled
893
+ if oauth_url:
894
+ from deepcell_cli.oauth_provider import DeepCellOAuthProvider
895
+
896
+ provider = srv._auth_server_provider
897
+ if isinstance(provider, DeepCellOAuthProvider):
898
+ _add_oauth_routes(srv, provider)
899
+
900
+ srv.run(transport="streamable-http")
901
+ else:
902
+ server.run(transport="stdio")
903
+
904
+
905
+ if __name__ == "__main__":
906
+ main()