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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- 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()
|