sourcecode 2.6.3__py3-none-any.whl → 2.6.10__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.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +162 -14
- sourcecode/file_classifier.py +6 -2
- sourcecode/mcp/onboarding/applier.py +29 -15
- sourcecode/mcp/onboarding/detector.py +38 -0
- sourcecode/mcp/onboarding/planner.py +2 -2
- sourcecode/mcp/server.py +45 -6
- sourcecode/mcp_nudge.py +3 -2
- sourcecode/readiness_timeline.py +178 -0
- sourcecode/repository_ir.py +15 -0
- sourcecode/spring_impact.py +1 -1
- sourcecode/spring_tx_analyzer.py +141 -4
- sourcecode/telemetry/__init__.py +2 -0
- sourcecode/telemetry/consent.py +2 -1
- sourcecode/telemetry/events.py +3 -0
- sourcecode/telemetry/filters.py +20 -1
- sourcecode/token_estimate.py +213 -0
- {sourcecode-2.6.3.dist-info → sourcecode-2.6.10.dist-info}/METADATA +1 -1
- {sourcecode-2.6.3.dist-info → sourcecode-2.6.10.dist-info}/RECORD +22 -20
- {sourcecode-2.6.3.dist-info → sourcecode-2.6.10.dist-info}/WHEEL +0 -0
- {sourcecode-2.6.3.dist-info → sourcecode-2.6.10.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.6.3.dist-info → sourcecode-2.6.10.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
import difflib
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
import threading
|
|
@@ -331,6 +332,54 @@ def _reject_path_before_subcommand(path_token: str, subcommand: str) -> "NoRetur
|
|
|
331
332
|
raise SystemExit(2)
|
|
332
333
|
|
|
333
334
|
|
|
335
|
+
def _reject_unknown_command(token: str) -> "NoReturn":
|
|
336
|
+
"""Refuse a first positional that is neither a known subcommand nor a real path.
|
|
337
|
+
|
|
338
|
+
`ask` accepts a bare repository path (`ask ./repo`) as an implicit scan, so a
|
|
339
|
+
mistyped subcommand used to be swallowed as a path and then surfaced a
|
|
340
|
+
misleading error naming the *wrong* token — `ask spring-audi` complained the
|
|
341
|
+
directory 'spring-audi' did not exist, and `ask notacommand /tmp` complained
|
|
342
|
+
'No such command /tmp' (the path, not the typo). Name the token the user
|
|
343
|
+
actually got wrong, and suggest the closest real commands.
|
|
344
|
+
"""
|
|
345
|
+
matches = difflib.get_close_matches(token, sorted(_SUBCOMMANDS), n=3, cutoff=0.6)
|
|
346
|
+
first = f"error: '{token}' is not a known command"
|
|
347
|
+
if matches:
|
|
348
|
+
first += f". Did you mean: {', '.join(matches)}?"
|
|
349
|
+
else:
|
|
350
|
+
first += " and is not an existing directory to scan."
|
|
351
|
+
print(
|
|
352
|
+
first + "\n run 'ask --help' to list commands, or pass an existing "
|
|
353
|
+
"repository path.",
|
|
354
|
+
file=sys.stderr,
|
|
355
|
+
)
|
|
356
|
+
raise SystemExit(2)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _has_trailing_nonsubcommand_positional(args: list[str], after: int) -> bool:
|
|
360
|
+
"""True when a bare positional that is NOT a subcommand appears after index
|
|
361
|
+
``after`` (options and the values they consume are skipped).
|
|
362
|
+
|
|
363
|
+
The implicit scan (`ask <path>`) owns exactly one positional, and a valid
|
|
364
|
+
invocation with two positionals always starts with a subcommand. So a first
|
|
365
|
+
non-subcommand positional followed by another non-subcommand positional —
|
|
366
|
+
`ask notacommand /tmp` — is malformed: the user meant a command. A trailing
|
|
367
|
+
token that IS a subcommand is left for ``_reject_path_before_subcommand``."""
|
|
368
|
+
skip_next = False
|
|
369
|
+
for arg in args[after + 1:]:
|
|
370
|
+
if skip_next:
|
|
371
|
+
skip_next = False
|
|
372
|
+
continue
|
|
373
|
+
if arg.startswith("-"):
|
|
374
|
+
if arg.split("=")[0] in _OPTIONS_WITH_VALUE and "=" not in arg:
|
|
375
|
+
skip_next = True
|
|
376
|
+
continue
|
|
377
|
+
if arg in _SUBCOMMANDS:
|
|
378
|
+
return False # wrong-order path/subcommand — handled elsewhere
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
334
383
|
def _preprocess_args(args: list[str]) -> list[str]:
|
|
335
384
|
"""Extract a repository path token from an args list and store it in _detected_path.
|
|
336
385
|
|
|
@@ -363,7 +412,13 @@ def _preprocess_args(args: list[str]) -> list[str]:
|
|
|
363
412
|
return result # known subcommand — leave for Click to dispatch
|
|
364
413
|
if _path_index >= 0:
|
|
365
414
|
continue # a later positional is the subcommand's own business
|
|
366
|
-
# First genuine positional
|
|
415
|
+
# First genuine positional. `ask` treats it as the repository path to scan
|
|
416
|
+
# (existence is validated downstream, not here). But if it is followed by
|
|
417
|
+
# another non-subcommand positional, the one-path scan cannot own both —
|
|
418
|
+
# `ask notacommand /tmp` — so the user meant a command: name the token they
|
|
419
|
+
# got wrong instead of swallowing it and erroring on the next one.
|
|
420
|
+
if _has_trailing_nonsubcommand_positional(result, i):
|
|
421
|
+
_reject_unknown_command(arg)
|
|
367
422
|
_set_detected_path(arg)
|
|
368
423
|
_path_index = i
|
|
369
424
|
if _path_index >= 0:
|
|
@@ -1278,11 +1333,23 @@ def main(
|
|
|
1278
1333
|
_raw_path_input = _get_detected_path()
|
|
1279
1334
|
target = Path(_raw_path_input).resolve()
|
|
1280
1335
|
if not target.exists():
|
|
1336
|
+
# A non-existent path that closely matches a command name is almost always a
|
|
1337
|
+
# mistyped subcommand (`ask spring-audi`) swallowed as an implicit-scan path,
|
|
1338
|
+
# not a real directory. Point at the likely command instead of only the path.
|
|
1339
|
+
_cmd_matches = difflib.get_close_matches(
|
|
1340
|
+
_raw_path_input, sorted(_SUBCOMMANDS), n=3, cutoff=0.6
|
|
1341
|
+
)
|
|
1342
|
+
_hint = "Pass an existing repository directory."
|
|
1343
|
+
if _cmd_matches:
|
|
1344
|
+
_hint = (
|
|
1345
|
+
f"If you meant a command, try: {', '.join('ask ' + m for m in _cmd_matches)}. "
|
|
1346
|
+
"Otherwise pass an existing repository directory."
|
|
1347
|
+
)
|
|
1281
1348
|
_emit_error_json(
|
|
1282
1349
|
INVALID_INPUT_CODE,
|
|
1283
1350
|
f"Directory '{_raw_path_input}' does not exist.",
|
|
1284
1351
|
path=_raw_path_input,
|
|
1285
|
-
hint=
|
|
1352
|
+
hint=_hint,
|
|
1286
1353
|
expected="An existing directory path.",
|
|
1287
1354
|
)
|
|
1288
1355
|
raise typer.Exit(code=1)
|
|
@@ -1784,6 +1851,10 @@ def main(
|
|
|
1784
1851
|
)
|
|
1785
1852
|
except Exception:
|
|
1786
1853
|
pass # stale value better than crash
|
|
1854
|
+
# C1: token economy on the agent-facing views (cache-hit path).
|
|
1855
|
+
if format == "json" and (compact or agent):
|
|
1856
|
+
from sourcecode.token_estimate import inject_token_economy as _inj_te
|
|
1857
|
+
_cache_hit_content = _inj_te(_cache_hit_content, target)
|
|
1787
1858
|
_emit_command_output(_cache_hit_content, output, copy)
|
|
1788
1859
|
return
|
|
1789
1860
|
|
|
@@ -2632,7 +2703,14 @@ def main(
|
|
|
2632
2703
|
}, indent=2, ensure_ascii=False)
|
|
2633
2704
|
except Exception:
|
|
2634
2705
|
pass
|
|
2635
|
-
|
|
2706
|
+
# C1: token economy on the agent-facing views (fresh path). Emit-only —
|
|
2707
|
+
# `content` stays clean so the cached L2 view never embeds a stale block
|
|
2708
|
+
# (the cache-hit path recomputes it against the working tree).
|
|
2709
|
+
_emit_content = content
|
|
2710
|
+
if format == "json" and (compact or agent):
|
|
2711
|
+
from sourcecode.token_estimate import inject_token_economy as _inj_te
|
|
2712
|
+
_emit_content = _inj_te(content, target)
|
|
2713
|
+
_emit_command_output(_emit_content, output, copy if not _pipeline_error else False)
|
|
2636
2714
|
|
|
2637
2715
|
# Persist to two-layer cache (git SHA unchanged → re-use on next run).
|
|
2638
2716
|
#
|
|
@@ -5528,7 +5606,7 @@ def spring_audit_cmd(
|
|
|
5528
5606
|
help="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
|
|
5529
5607
|
),
|
|
5530
5608
|
) -> None:
|
|
5531
|
-
"""Spring semantic audit: TX anomalies (TX-001..
|
|
5609
|
+
"""Spring semantic audit: TX anomalies (TX-001..006) + security surface (SEC-001..003).
|
|
5532
5610
|
|
|
5533
5611
|
\b
|
|
5534
5612
|
Detects:
|
|
@@ -5537,6 +5615,7 @@ def spring_audit_cmd(
|
|
|
5537
5615
|
TX-003 readOnly=true boundary propagating to write operation
|
|
5538
5616
|
TX-004 NOT_SUPPORTED/NEVER within active TX chain
|
|
5539
5617
|
TX-005 Exception swallowing inside @Transactional
|
|
5618
|
+
TX-006 Self-invocation of @Transactional sibling (proxy bypass)
|
|
5540
5619
|
SEC-001 Unsecured endpoint in annotation_based security model
|
|
5541
5620
|
SEC-002 CVE-2025-41248: @PreAuthorize on inherited method from generic supertype
|
|
5542
5621
|
SEC-003 @Transactional on @Controller/@RestController (TX in wrong layer)
|
|
@@ -5805,6 +5884,23 @@ def migrate_check_cmd(
|
|
|
5805
5884
|
False, "--no-cache",
|
|
5806
5885
|
help="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
|
|
5807
5886
|
),
|
|
5887
|
+
snapshot: bool = typer.Option(
|
|
5888
|
+
False, "--snapshot",
|
|
5889
|
+
help="Persist this readiness result as a timeline snapshot under --history-dir.",
|
|
5890
|
+
),
|
|
5891
|
+
trend: bool = typer.Option(
|
|
5892
|
+
False, "--trend",
|
|
5893
|
+
help="Report the readiness trend across stored snapshots (days-remaining over time) "
|
|
5894
|
+
"instead of scanning. Reads --history-dir.",
|
|
5895
|
+
),
|
|
5896
|
+
history_dir: Optional[Path] = typer.Option(
|
|
5897
|
+
None, "--history-dir",
|
|
5898
|
+
help="Directory of readiness snapshots (default: <repo>/.ask/readiness-history).",
|
|
5899
|
+
),
|
|
5900
|
+
ref: Optional[str] = typer.Option(
|
|
5901
|
+
None, "--ref",
|
|
5902
|
+
help="Label for a --snapshot capture (e.g. a version or sprint tag).",
|
|
5903
|
+
),
|
|
5808
5904
|
) -> None:
|
|
5809
5905
|
"""Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
|
|
5810
5906
|
|
|
@@ -5836,6 +5932,16 @@ def migrate_check_cmd(
|
|
|
5836
5932
|
ask migrate-check /path/to/repo --format text
|
|
5837
5933
|
ask migrate-check . --min-severity high
|
|
5838
5934
|
ask migrate-check . --output migration.json
|
|
5935
|
+
ask migrate-check . --snapshot --ref sprint-12 persist a readiness point
|
|
5936
|
+
ask migrate-check . --trend days-remaining over time
|
|
5937
|
+
|
|
5938
|
+
\b
|
|
5939
|
+
Readiness time series:
|
|
5940
|
+
--snapshot persists this run's budgetable figures (readiness_score, effort
|
|
5941
|
+
days, per-dimension scores, blocking_count) under --history-dir (default
|
|
5942
|
+
<repo>/.ask/readiness-history). --trend reads that series and reports
|
|
5943
|
+
first→last movement — no improving/degrading label, and readiness_score is
|
|
5944
|
+
flagged not-comparable when the applicable dimension set changed.
|
|
5839
5945
|
"""
|
|
5840
5946
|
from sourcecode.repository_ir import find_java_files
|
|
5841
5947
|
from sourcecode.migrate_check import run_migrate_check
|
|
@@ -5862,6 +5968,29 @@ def migrate_check_cmd(
|
|
|
5862
5968
|
)
|
|
5863
5969
|
raise typer.Exit(code=1)
|
|
5864
5970
|
|
|
5971
|
+
_history_dir = history_dir.resolve() if history_dir else (target / ".ask" / "readiness-history")
|
|
5972
|
+
|
|
5973
|
+
# --trend reads the stored series and reports movement over time; it does not scan.
|
|
5974
|
+
if trend:
|
|
5975
|
+
from sourcecode.readiness_timeline import build_readiness_trend, load_snapshots_dir
|
|
5976
|
+
|
|
5977
|
+
_snaps = load_snapshots_dir(_history_dir) if _history_dir.exists() else []
|
|
5978
|
+
if not _snaps:
|
|
5979
|
+
_emit_error_json(
|
|
5980
|
+
INVALID_INPUT_CODE,
|
|
5981
|
+
f"No readiness snapshots found in '{_history_dir}'.",
|
|
5982
|
+
path=str(_history_dir),
|
|
5983
|
+
hint="Capture points first: ask migrate-check <repo> --snapshot",
|
|
5984
|
+
expected="A directory holding readiness-snapshot-v1 artifacts.",
|
|
5985
|
+
)
|
|
5986
|
+
raise typer.Exit(code=1)
|
|
5987
|
+
_trend = build_readiness_trend(_snaps)
|
|
5988
|
+
_emit_command_output(
|
|
5989
|
+
_serialize_dict(_trend, "json"), output_path, copy,
|
|
5990
|
+
success_msg=f"Readiness trend over {_trend['count']} snapshot(s) → {_history_dir}",
|
|
5991
|
+
)
|
|
5992
|
+
return
|
|
5993
|
+
|
|
5865
5994
|
_file_limitations: list[str] = []
|
|
5866
5995
|
file_list = find_java_files(target, limitations=_file_limitations)
|
|
5867
5996
|
_prog = Progress()
|
|
@@ -5882,6 +6011,14 @@ def migrate_check_cmd(
|
|
|
5882
6011
|
payload = report.to_compact_dict() if compact else report.to_dict()
|
|
5883
6012
|
output = _serialize_dict(payload, "json")
|
|
5884
6013
|
|
|
6014
|
+
_snapshot_note = ""
|
|
6015
|
+
if snapshot:
|
|
6016
|
+
from sourcecode.readiness_timeline import build_snapshot, write_snapshot
|
|
6017
|
+
|
|
6018
|
+
_snap = build_snapshot(report.to_dict(), ref=ref)
|
|
6019
|
+
_snap_path = write_snapshot(_snap, _history_dir)
|
|
6020
|
+
_snapshot_note = f"; snapshot → {_snap_path}"
|
|
6021
|
+
|
|
5885
6022
|
_total = report.summary.get("total_findings", 0)
|
|
5886
6023
|
_emit_command_output(
|
|
5887
6024
|
output, output_path, copy,
|
|
@@ -5889,6 +6026,7 @@ def migrate_check_cmd(
|
|
|
5889
6026
|
f"Migration check written to {output_path} "
|
|
5890
6027
|
f"(score: {report.readiness_score if report.readiness_score is not None else 'N/A'}"
|
|
5891
6028
|
f"{'/100' if report.readiness_score is not None else ''}, {_total} findings)"
|
|
6029
|
+
f"{_snapshot_note}"
|
|
5892
6030
|
),
|
|
5893
6031
|
)
|
|
5894
6032
|
|
|
@@ -7897,11 +8035,15 @@ def cold_start_cmd(
|
|
|
7897
8035
|
result["endpoints"] = result["endpoints"][:30]
|
|
7898
8036
|
result["_meta"] = {**(result.get("_meta") or {}), "compact_mode": True,
|
|
7899
8037
|
"full_available": "ask cold-start (without --compact)"}
|
|
8038
|
+
from sourcecode.token_estimate import estimate_tokens as _est_tokens, token_economy as _tok_econ
|
|
7900
8039
|
_out = _json.dumps(result, indent=2, ensure_ascii=False)
|
|
7901
8040
|
_size = len(_out.encode("utf-8"))
|
|
7902
|
-
_tokens =
|
|
8041
|
+
_tokens = _est_tokens(_out)
|
|
7903
8042
|
_out_with_meta = _json.loads(_out)
|
|
7904
|
-
_out_with_meta.setdefault("_meta", {})
|
|
8043
|
+
_meta_cs = _out_with_meta.setdefault("_meta", {})
|
|
8044
|
+
_meta_cs["estimated_tokens"] = _tokens
|
|
8045
|
+
# C1: what this response costs vs. what reading the same files raw would.
|
|
8046
|
+
_meta_cs["token_economy"] = _tok_econ(_out, _out_with_meta, target)
|
|
7905
8047
|
_out = _json.dumps(_out_with_meta, indent=2, ensure_ascii=False)
|
|
7906
8048
|
if not compact and _size > 400_000:
|
|
7907
8049
|
sys.stderr.write(
|
|
@@ -8018,7 +8160,9 @@ def mcp_init(
|
|
|
8018
8160
|
typer.echo("No MCP clients found on this system.")
|
|
8019
8161
|
typer.echo("")
|
|
8020
8162
|
typer.echo("Manual setup — add to your MCP client config:")
|
|
8021
|
-
typer.echo(' "
|
|
8163
|
+
typer.echo(' "ask": {"command": "ask", "args": ["mcp", "serve"]}')
|
|
8164
|
+
typer.echo(' (VS Code keys these under "servers" and wants "type": "stdio";')
|
|
8165
|
+
typer.echo(' other clients use "mcpServers".)')
|
|
8022
8166
|
raise typer.Exit(code=0)
|
|
8023
8167
|
|
|
8024
8168
|
# Show detection results
|
|
@@ -8070,7 +8214,9 @@ def mcp_init(
|
|
|
8070
8214
|
if a.client.config_path.exists():
|
|
8071
8215
|
bak = backup.create(a.client.config_path)
|
|
8072
8216
|
typer.echo(f" ✓ Backup {bak}")
|
|
8073
|
-
updated = applier.apply_entry(
|
|
8217
|
+
updated = applier.apply_entry(
|
|
8218
|
+
config, a.client.servers_key, a.client.entry_extra
|
|
8219
|
+
)
|
|
8074
8220
|
applier.write_config(a.client.config_path, updated)
|
|
8075
8221
|
if not applier.validate(a.client.config_path):
|
|
8076
8222
|
errors.append(f"{a.client.name}: JSON validation failed after write")
|
|
@@ -8154,16 +8300,18 @@ def mcp_status() -> None:
|
|
|
8154
8300
|
typer.echo(f" Fix: ask mcp init --target {client.slug}")
|
|
8155
8301
|
continue
|
|
8156
8302
|
config = applier.read_config(client.config_path)
|
|
8157
|
-
if applier.is_installed(config):
|
|
8303
|
+
if applier.is_installed(config, client.servers_key):
|
|
8158
8304
|
typer.echo(f" {client.name:<20} ✓ configured {client.config_path}")
|
|
8159
8305
|
# FIX-P0-5: inspect registered command for external-server drift.
|
|
8160
|
-
|
|
8306
|
+
# Reads through the client's own servers key and entry name, so drift is
|
|
8307
|
+
# still detected for clients that key their servers differently.
|
|
8308
|
+
_registered = applier.registered_entry(config, client.servers_key)
|
|
8161
8309
|
_reg_cmd = _registered.get("command", "")
|
|
8162
8310
|
_reg_args = _registered.get("args", [])
|
|
8163
8311
|
# Built-in form: command=sourcecode args=[mcp, serve] (or just the binary)
|
|
8164
8312
|
_is_builtin = (
|
|
8165
|
-
_reg_cmd
|
|
8166
|
-
or (not _reg_args and _reg_cmd.endswith("/sourcecode"))
|
|
8313
|
+
_reg_cmd in ("ask", "sourcecode")
|
|
8314
|
+
or (not _reg_args and _reg_cmd.endswith(("/ask", "/sourcecode")))
|
|
8167
8315
|
or (_reg_args and _reg_args[:2] == ["mcp", "serve"])
|
|
8168
8316
|
)
|
|
8169
8317
|
if _is_builtin:
|
|
@@ -8224,7 +8372,7 @@ def mcp_status() -> None:
|
|
|
8224
8372
|
for _c in clients:
|
|
8225
8373
|
if _c.app_installed:
|
|
8226
8374
|
_cfg = applier.read_config(_c.config_path)
|
|
8227
|
-
if applier.is_installed(_cfg):
|
|
8375
|
+
if applier.is_installed(_cfg, _c.servers_key):
|
|
8228
8376
|
_configured_clients.add(_c.slug)
|
|
8229
8377
|
|
|
8230
8378
|
# Stage 3: Process liveness — is the client app currently running?
|
|
@@ -8304,7 +8452,7 @@ def mcp_remove(
|
|
|
8304
8452
|
bak = backup.create(a.client.config_path)
|
|
8305
8453
|
typer.echo(f" ✓ Backup {bak}")
|
|
8306
8454
|
config = applier.read_config(a.client.config_path)
|
|
8307
|
-
updated = applier.remove_entry(config)
|
|
8455
|
+
updated = applier.remove_entry(config, a.client.servers_key)
|
|
8308
8456
|
applier.write_config(a.client.config_path, updated)
|
|
8309
8457
|
if not applier.validate(a.client.config_path):
|
|
8310
8458
|
errors.append(f"{a.client.name}: JSON validation failed — restoring backup")
|
sourcecode/file_classifier.py
CHANGED
|
@@ -300,14 +300,18 @@ class FileClassifier:
|
|
|
300
300
|
found = frozenset(m.group(1) for m in _JAVA_ANNOTATION_RE.finditer(content))
|
|
301
301
|
if not found:
|
|
302
302
|
return None
|
|
303
|
+
# Sorted, not set order: this list is emitted as `evidence` in the agent view,
|
|
304
|
+
# and frozenset iteration order varies with PYTHONHASHSEED — the same repo
|
|
305
|
+
# analysed twice produced the same evidence in a different order.
|
|
306
|
+
evidence = sorted(found)
|
|
303
307
|
for required_annotations, category, relevance, why in _JAVA_STEREOTYPE_RULES:
|
|
304
308
|
# For @Data DTO: must have @Data but NOT @Entity
|
|
305
309
|
if required_annotations == frozenset({"Data"}):
|
|
306
310
|
if "Data" in found and "Entity" not in found:
|
|
307
|
-
return FileClassification(path, category, "high", relevance, why,
|
|
311
|
+
return FileClassification(path, category, "high", relevance, why, evidence)
|
|
308
312
|
continue
|
|
309
313
|
# For compound rules (Service+Transactional, Controller+RequestMapping): all required
|
|
310
314
|
if required_annotations <= found:
|
|
311
|
-
return FileClassification(path, category, "high", relevance, why,
|
|
315
|
+
return FileClassification(path, category, "high", relevance, why, evidence)
|
|
312
316
|
return None
|
|
313
317
|
|
|
@@ -25,36 +25,50 @@ def read_config(path: Path) -> dict:
|
|
|
25
25
|
return {}
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def is_installed(config: dict) -> bool:
|
|
28
|
+
def is_installed(config: dict, servers_key: str = _MCP_SERVERS_KEY) -> bool:
|
|
29
29
|
"""True if the ASK Engine entry (canonical `ask` or legacy `sourcecode`) is
|
|
30
|
-
already present in
|
|
31
|
-
servers = config.get(
|
|
30
|
+
already present in the client's servers map."""
|
|
31
|
+
servers = config.get(servers_key, {})
|
|
32
32
|
return _ENTRY_NAME in servers or _LEGACY_ENTRY_NAME in servers
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
def registered_entry(config: dict, servers_key: str = _MCP_SERVERS_KEY) -> dict:
|
|
36
|
+
"""The ASK Engine server entry as currently registered, or an empty dict."""
|
|
37
|
+
servers = config.get(servers_key, {})
|
|
38
|
+
if not isinstance(servers, dict):
|
|
39
|
+
return {}
|
|
40
|
+
entry = servers.get(_ENTRY_NAME) or servers.get(_LEGACY_ENTRY_NAME) or {}
|
|
41
|
+
return entry if isinstance(entry, dict) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_entry(
|
|
45
|
+
config: dict,
|
|
46
|
+
servers_key: str = _MCP_SERVERS_KEY,
|
|
47
|
+
entry_extra: tuple[tuple[str, str], ...] = (),
|
|
48
|
+
) -> dict:
|
|
49
|
+
"""Return new config dict with the canonical `ask` entry merged into the client's
|
|
50
|
+
servers map. Any legacy `sourcecode` entry is migrated away (removed) so the client
|
|
51
|
+
launches a single server. *entry_extra* carries per-client fields (e.g. VS Code's
|
|
52
|
+
explicit `type: stdio`)."""
|
|
39
53
|
config = dict(config)
|
|
40
|
-
servers: dict = dict(config.get(
|
|
54
|
+
servers: dict = dict(config.get(servers_key, {}))
|
|
41
55
|
servers.pop(_LEGACY_ENTRY_NAME, None)
|
|
42
|
-
servers[_ENTRY_NAME] = _ENTRY_VALUE
|
|
43
|
-
config[
|
|
56
|
+
servers[_ENTRY_NAME] = {**_ENTRY_VALUE, **dict(entry_extra)}
|
|
57
|
+
config[servers_key] = servers
|
|
44
58
|
return config
|
|
45
59
|
|
|
46
60
|
|
|
47
|
-
def remove_entry(config: dict) -> dict:
|
|
61
|
+
def remove_entry(config: dict, servers_key: str = _MCP_SERVERS_KEY) -> dict:
|
|
48
62
|
"""Return new config dict with the ASK Engine entry removed — both the canonical
|
|
49
63
|
`ask` key and any legacy `sourcecode` key."""
|
|
50
64
|
config = dict(config)
|
|
51
|
-
servers: dict = dict(config.get(
|
|
65
|
+
servers: dict = dict(config.get(servers_key, {}))
|
|
52
66
|
servers.pop(_ENTRY_NAME, None)
|
|
53
67
|
servers.pop(_LEGACY_ENTRY_NAME, None)
|
|
54
68
|
if servers:
|
|
55
|
-
config[
|
|
56
|
-
elif
|
|
57
|
-
del config[
|
|
69
|
+
config[servers_key] = servers
|
|
70
|
+
elif servers_key in config:
|
|
71
|
+
del config[servers_key]
|
|
58
72
|
return config
|
|
59
73
|
|
|
60
74
|
|
|
@@ -38,6 +38,38 @@ _CLIENT_REGISTRY: List[Dict[str, Any]] = [
|
|
|
38
38
|
"win32": "Cursor",
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
"name": "Windsurf",
|
|
43
|
+
"slug": "windsurf",
|
|
44
|
+
"paths": {
|
|
45
|
+
"darwin": "~/.codeium/windsurf/mcp_config.json",
|
|
46
|
+
"linux": "~/.codeium/windsurf/mcp_config.json",
|
|
47
|
+
"win32": "{USERPROFILE}/.codeium/windsurf/mcp_config.json",
|
|
48
|
+
},
|
|
49
|
+
"process": {
|
|
50
|
+
"darwin": "Windsurf",
|
|
51
|
+
"linux": "windsurf",
|
|
52
|
+
"win32": "Windsurf",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
# VS Code keys its servers under "servers" (not "mcpServers") and wants an
|
|
57
|
+
# explicit transport on each entry — hence the per-client overrides below.
|
|
58
|
+
"name": "VS Code",
|
|
59
|
+
"slug": "vscode",
|
|
60
|
+
"paths": {
|
|
61
|
+
"darwin": "~/Library/Application Support/Code/User/mcp.json",
|
|
62
|
+
"linux": "~/.config/Code/User/mcp.json",
|
|
63
|
+
"win32": "{APPDATA}/Code/User/mcp.json",
|
|
64
|
+
},
|
|
65
|
+
"process": {
|
|
66
|
+
"darwin": "Code Helper",
|
|
67
|
+
"linux": "code",
|
|
68
|
+
"win32": "Code",
|
|
69
|
+
},
|
|
70
|
+
"servers_key": "servers",
|
|
71
|
+
"entry_extra": (("type", "stdio"),),
|
|
72
|
+
},
|
|
41
73
|
]
|
|
42
74
|
|
|
43
75
|
|
|
@@ -48,6 +80,10 @@ class MCPClient:
|
|
|
48
80
|
app_installed: bool # True if the config file (or its parent dir) exists
|
|
49
81
|
process_name: str # OS process name for connectivity check
|
|
50
82
|
slug: str # --target identifier (e.g. "claude-desktop")
|
|
83
|
+
# Config-shape differences between clients. Defaults match the common shape
|
|
84
|
+
# (Claude Desktop / Cursor / Windsurf), so only divergent clients declare them.
|
|
85
|
+
servers_key: str = "mcpServers" # top-level key holding the servers map
|
|
86
|
+
entry_extra: tuple[tuple[str, str], ...] = () # extra fields on the server entry
|
|
51
87
|
|
|
52
88
|
|
|
53
89
|
def _resolve(template: str) -> Path:
|
|
@@ -79,6 +115,8 @@ def detect_clients() -> list[MCPClient]:
|
|
|
79
115
|
app_installed=app_installed,
|
|
80
116
|
process_name=process_name,
|
|
81
117
|
slug=entry["slug"],
|
|
118
|
+
servers_key=entry.get("servers_key", "mcpServers"),
|
|
119
|
+
entry_extra=tuple(entry.get("entry_extra", ())),
|
|
82
120
|
))
|
|
83
121
|
return clients
|
|
84
122
|
|
|
@@ -21,7 +21,7 @@ def build_install_plan(clients: list[MCPClient]) -> list[ClientAction]:
|
|
|
21
21
|
config = read_config(client.config_path)
|
|
22
22
|
actions.append(ClientAction(
|
|
23
23
|
client=client,
|
|
24
|
-
already_installed=is_installed(config),
|
|
24
|
+
already_installed=is_installed(config, client.servers_key),
|
|
25
25
|
will_create_file=not client.config_path.exists(),
|
|
26
26
|
))
|
|
27
27
|
return actions
|
|
@@ -34,7 +34,7 @@ def build_remove_plan(clients: list[MCPClient]) -> list[ClientAction]:
|
|
|
34
34
|
config = read_config(client.config_path)
|
|
35
35
|
actions.append(ClientAction(
|
|
36
36
|
client=client,
|
|
37
|
-
already_installed=is_installed(config),
|
|
37
|
+
already_installed=is_installed(config, client.servers_key),
|
|
38
38
|
will_create_file=False,
|
|
39
39
|
))
|
|
40
40
|
return actions
|
sourcecode/mcp/server.py
CHANGED
|
@@ -28,21 +28,54 @@ from sourcecode.error_schema import (
|
|
|
28
28
|
)
|
|
29
29
|
from sourcecode.mcp.runner import CommandError, run_command
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
def _record_tool_invocation(name: Any, success: bool, started: float) -> None:
|
|
32
|
+
"""Count one MCP tool invocation (Bloque C / C3 — adoption).
|
|
33
|
+
|
|
34
|
+
Aggregate only: which of our own tools ran, whether it succeeded, and a
|
|
35
|
+
duration bucket. Never the arguments — those carry repository paths — and
|
|
36
|
+
never any result content. Honours the same opt-out as every other event
|
|
37
|
+
(`ask telemetry disable`, SOURCECODE_TELEMETRY=0, DO_NOT_TRACK=1); when
|
|
38
|
+
telemetry is off, `record` returns before building anything.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
import time as _time
|
|
42
|
+
|
|
43
|
+
from sourcecode import telemetry as _tel
|
|
44
|
+
|
|
45
|
+
_tel.record(
|
|
46
|
+
"mcp_tool_invoked",
|
|
47
|
+
cmd="mcp",
|
|
48
|
+
tool=str(name) if name else None,
|
|
49
|
+
duration_s=_time.monotonic() - started,
|
|
50
|
+
success=success,
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass # telemetry must never affect a tool call
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Patch FastMCP's Tool.run to (a) count the invocation and (b) intercept
|
|
57
|
+
# pydantic.ValidationError and return structured JSON instead of the raw
|
|
58
|
+
# "Error executing tool X: 1 validation error..." plain-text string that FastMCP
|
|
59
|
+
# produces by default.
|
|
34
60
|
try:
|
|
35
|
-
import
|
|
61
|
+
import time as _time_mod
|
|
62
|
+
|
|
36
63
|
from mcp.server.fastmcp.tools.base import Tool as _FastMCPTool
|
|
37
64
|
|
|
65
|
+
try:
|
|
66
|
+
import pydantic as _pydantic
|
|
67
|
+
except Exception: # pragma: no cover — counting must not depend on pydantic
|
|
68
|
+
_pydantic = None # type: ignore[assignment]
|
|
69
|
+
|
|
38
70
|
_orig_tool_run = _FastMCPTool.run
|
|
39
71
|
|
|
40
72
|
async def _patched_tool_run(self, arguments, context=None, convert_result=False): # type: ignore[override]
|
|
73
|
+
_started = _time_mod.monotonic()
|
|
41
74
|
try:
|
|
42
|
-
|
|
75
|
+
_result = await _orig_tool_run(self, arguments, context=context, convert_result=convert_result)
|
|
43
76
|
except Exception as _exc:
|
|
44
77
|
_cause = getattr(_exc, "__cause__", None)
|
|
45
|
-
if isinstance(_cause, _pydantic.ValidationError):
|
|
78
|
+
if _pydantic is not None and isinstance(_cause, _pydantic.ValidationError):
|
|
46
79
|
_errors = _cause.errors()
|
|
47
80
|
_missing = [str(e.get("loc", ("?",))[0]) for e in _errors if e.get("type") == "missing"]
|
|
48
81
|
_msg = f"Missing required field: {_missing[0]}" if _missing else "Argument validation failed"
|
|
@@ -56,8 +89,14 @@ try:
|
|
|
56
89
|
expected=f"{self.name} arguments with required field '{_missing[0]}'" if _missing else f"{self.name} arguments",
|
|
57
90
|
),
|
|
58
91
|
}
|
|
92
|
+
_record_tool_invocation(self.name, False, _started)
|
|
59
93
|
return _payload
|
|
94
|
+
_record_tool_invocation(self.name, False, _started)
|
|
60
95
|
raise
|
|
96
|
+
_record_tool_invocation(
|
|
97
|
+
self.name, not getattr(_result, "isError", False), _started
|
|
98
|
+
)
|
|
99
|
+
return _result
|
|
61
100
|
|
|
62
101
|
_FastMCPTool.run = _patched_tool_run # type: ignore[method-assign]
|
|
63
102
|
except Exception:
|
sourcecode/mcp_nudge.py
CHANGED
|
@@ -41,7 +41,7 @@ except Exception: # pragma: no cover
|
|
|
41
41
|
def detect_clients() -> list: # type: ignore[misc]
|
|
42
42
|
return []
|
|
43
43
|
|
|
44
|
-
def is_installed(config: dict) -> bool: # type: ignore[misc]
|
|
44
|
+
def is_installed(config: dict, servers_key: str = "mcpServers") -> bool: # type: ignore[misc]
|
|
45
45
|
return False
|
|
46
46
|
|
|
47
47
|
def read_config(path: Path) -> dict: # type: ignore[misc]
|
|
@@ -60,7 +60,8 @@ def nudge_mcp_if_needed() -> None:
|
|
|
60
60
|
return
|
|
61
61
|
|
|
62
62
|
needs_nudge = any(
|
|
63
|
-
c.app_installed
|
|
63
|
+
c.app_installed
|
|
64
|
+
and not is_installed(read_config(c.config_path), getattr(c, "servers_key", "mcpServers"))
|
|
64
65
|
for c in clients
|
|
65
66
|
)
|
|
66
67
|
|