maxc-cli 0.6.0__tar.gz → 0.7.0__tar.gz

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 (134) hide show
  1. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/PKG-INFO +1 -1
  2. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/__init__.py +1 -1
  3. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/_samples.py +16 -0
  4. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/app.py +285 -0
  5. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/auth_providers.py +4 -0
  6. maxc_cli-0.7.0/src/maxc_cli/backend/mcp.py +320 -0
  7. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/odps.py +83 -36
  8. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/cli.py +254 -0
  9. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/config.py +45 -0
  10. maxc_cli-0.7.0/src/maxc_cli/enterprise_tls.py +168 -0
  11. maxc_cli-0.7.0/src/maxc_cli/mcp_serve.py +264 -0
  12. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/models.py +16 -1
  13. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/oauth.py +25 -2
  14. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/output.py +37 -0
  15. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/SKILL.md +37 -0
  16. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/command-patterns.md +2 -0
  17. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  18. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/SOURCES.txt +7 -0
  19. maxc_cli-0.7.0/tests/test_backend_mcp.py +419 -0
  20. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_build_release_archive_compat.py +38 -0
  21. maxc_cli-0.7.0/tests/test_enterprise_tls.py +139 -0
  22. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_installer_contracts.py +130 -8
  23. maxc_cli-0.7.0/tests/test_kb_commands.py +497 -0
  24. maxc_cli-0.7.0/tests/test_mcp_serve.py +352 -0
  25. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_oauth.py +113 -1
  26. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_query_result_csv_fallback.py +120 -2
  27. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/MANIFEST.in +0 -0
  28. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/README.md +0 -0
  29. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/pyproject.toml +0 -0
  30. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/scripts/pyinstaller_entry.py +0 -0
  31. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/scripts/regression_test.py +0 -0
  32. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/setup.cfg +0 -0
  33. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/setup.py +0 -0
  34. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/__main__.py +0 -0
  35. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/agent_platforms.py +0 -0
  36. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/audit.py +0 -0
  37. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/auth_continuation.py +0 -0
  38. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/__init__.py +0 -0
  39. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/auth.py +0 -0
  40. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/catalog.py +0 -0
  41. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/data.py +0 -0
  42. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/job.py +0 -0
  43. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/meta.py +0 -0
  44. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/query.py +0 -0
  45. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/semantic.py +0 -0
  46. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/cache.py +0 -0
  47. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/catalog_bootstrap.py +0 -0
  48. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/exceptions.py +0 -0
  49. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/help_format.py +0 -0
  50. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/helpers.py +0 -0
  51. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/job_ids.py +0 -0
  52. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/masking.py +0 -0
  53. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/odps_runtime.py +0 -0
  54. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/proxy_auth.py +0 -0
  55. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/semantic.py +0 -0
  56. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/semantic_management.py +0 -0
  57. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/setting_parser.py +0 -0
  58. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  59. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  60. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  61. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  62. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  63. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  64. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  65. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/red-lines.md +0 -0
  66. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/semantic-packages.md +0 -0
  67. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/setup-install.md +0 -0
  68. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  69. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  70. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  71. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/state_permissions.py +0 -0
  72. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/store.py +0 -0
  73. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/utils.py +0 -0
  74. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  75. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  76. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/requires.txt +0 -0
  77. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/top_level.txt +0 -0
  78. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_hints_and_cli.py +0 -0
  79. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_platforms.py +0 -0
  80. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_skill_commands.py +0 -0
  81. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_skill_commands_context.py +0 -0
  82. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_ai_native_contract_regressions.py +0 -0
  83. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_auth_logout.py +0 -0
  84. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_auth.py +0 -0
  85. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_data.py +0 -0
  86. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_data_serialization.py +0 -0
  87. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_meta.py +0 -0
  88. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_build_release_script.py +0 -0
  89. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cache.py +0 -0
  90. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_catalog.py +0 -0
  91. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_catalog_bootstrap.py +0 -0
  92. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_arg_validation.py +0 -0
  93. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_mock.py +0 -0
  94. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  95. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_compat.py +0 -0
  96. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_config_atomic_write.py +0 -0
  97. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_e2e_smoke.py +0 -0
  98. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_effective_hints_contract.py +0 -0
  99. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_envelope_shape.py +0 -0
  100. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_error_self_correction.py +0 -0
  101. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_error_translation.py +0 -0
  102. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_exit_codes.py +0 -0
  103. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_external_auth.py +0 -0
  104. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_flag_hoist.py +0 -0
  105. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_help_format.py +0 -0
  106. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_help_version_e2e.py +0 -0
  107. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_helpers.py +0 -0
  108. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_helpers_csv.py +0 -0
  109. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_integration.py +0 -0
  110. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_integration_real.py +0 -0
  111. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_job_improvements.py +0 -0
  112. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_job_store_durability.py +0 -0
  113. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_manifest_runtime_contract.py +0 -0
  114. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_masking.py +0 -0
  115. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_meta_schema_and_partition_cols.py +0 -0
  116. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_odps_runtime.py +0 -0
  117. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_output_action_safety.py +0 -0
  118. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_output_format_contract.py +0 -0
  119. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_packaging_metadata.py +0 -0
  120. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_phase1_improvements.py +0 -0
  121. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_proxy_auth.py +0 -0
  122. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_pyinstaller_bundle.py +0 -0
  123. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_python39_compat.py +0 -0
  124. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_query_auto_promote.py +0 -0
  125. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_management.py +0 -0
  126. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_scope.py +0 -0
  127. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_transport.py +0 -0
  128. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_setting_parser.py +0 -0
  129. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_cli_consistency.py +0 -0
  130. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_eval.py +0 -0
  131. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_renderer.py +0 -0
  132. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_startup_imports.py +0 -0
  133. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_state_permissions.py +0 -0
  134. {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_state_portability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.7.0"
@@ -84,6 +84,22 @@ SAMPLES: dict[str, str] = {
84
84
  "maxc meta search orders\n"
85
85
  "maxc meta search user --project my_proj --json"
86
86
  ),
87
+ # ── kb ─────────────────────────────────────────────────────────────────
88
+ "kb": "maxc kb ask \"How do I set a split size hint?\" --json\nmaxc kb search \"dynamic filter\" --json",
89
+ "kb.ask": (
90
+ "maxc kb ask \"How do I set a split size hint?\"\n"
91
+ "maxc kb ask \"What does ODPS-0123144 mean?\" --max-docs 3 --json"
92
+ ),
93
+ "kb.search": (
94
+ "maxc kb search \"clustered table bucket\"\n"
95
+ "maxc kb search \"dynamic filter\" --limit 5 --context-lines 3 --json"
96
+ ),
97
+ # ── mcp ────────────────────────────────────────────────────────────────
98
+ "mcp": 'maxc mcp serve # point an MCP client at {"command": "maxc", "args": ["mcp", "serve"]}',
99
+ "mcp.serve": (
100
+ 'maxc mcp serve\n'
101
+ '# MCP client config: {"command": "maxc", "args": ["mcp", "serve"]}'
102
+ ),
87
103
  "meta.search-columns": (
88
104
  "maxc meta search-columns user_id\n"
89
105
  "maxc meta search-columns dt --project my_proj --json"
@@ -3836,6 +3836,288 @@ class MaxCApp:
3836
3836
  self.log("meta.list-projects", envelope.status, envelope.metadata)
3837
3837
  return envelope
3838
3838
 
3839
+ # --- Knowledge base (public MCP) ------------------------------------
3840
+
3841
+ def _mcp_client(self):
3842
+ """Build a stateless MCP client authorized by the already-resolved credentials.
3843
+
3844
+ Raises ``FeatureUnavailableError`` rather than silently falling back: an agent
3845
+ that reads "no results" and "KB unreachable" as the same signal would conclude
3846
+ the documentation does not cover its question.
3847
+ """
3848
+ from .backend.mcp import (
3849
+ CatalogMcpTokenProvider,
3850
+ McpError,
3851
+ McpHttpClient,
3852
+ build_catalog_mint,
3853
+ default_endpoint,
3854
+ )
3855
+
3856
+ if not self.config.mcp.enabled:
3857
+ raise FeatureUnavailableError(
3858
+ "Knowledge-base commands need the MaxCompute MCP endpoint enabled.",
3859
+ suggestion=(
3860
+ "Set `mcp.enabled: true` in the maxc config. The bearer is minted "
3861
+ "from your existing MaxCompute credentials, so no separate login "
3862
+ "is required."
3863
+ ),
3864
+ )
3865
+ if self.backend is None or not hasattr(self.backend, "_catalog_rest"):
3866
+ raise FeatureUnavailableError(
3867
+ "Knowledge-base commands need an authenticated backend to mint an MCP token.",
3868
+ suggestion="Configure credentials first: `maxc auth whoami --json`.",
3869
+ )
3870
+ catalog_rest = self.backend._catalog_rest
3871
+ if catalog_rest is None:
3872
+ raise BackendConnectionError(
3873
+ "Could not reach CatalogAPI, which mints the MCP access token.",
3874
+ suggestion=(
3875
+ "Verify connectivity and identity: `maxc agent doctor --online --json`."
3876
+ ),
3877
+ )
3878
+ endpoint = (self.config.mcp.endpoint or "").strip() or default_endpoint(
3879
+ self.config.default_region
3880
+ )
3881
+ try:
3882
+ tokens = CatalogMcpTokenProvider(
3883
+ build_catalog_mint(catalog_rest, catalog_rest.endpoint or "")
3884
+ )
3885
+ return McpHttpClient(
3886
+ endpoint,
3887
+ tokens,
3888
+ timeout=self.config.mcp.timeout_seconds,
3889
+ client_version=__version__,
3890
+ )
3891
+ except McpError as exc:
3892
+ raise BackendConnectionError(str(exc)) from exc
3893
+
3894
+ @staticmethod
3895
+ def _kb_structured(result: 'dict[str, Any]') -> 'dict[str, Any]':
3896
+ structured = result.get("structuredContent")
3897
+ if not isinstance(structured, dict):
3898
+ raise BackendConnectionError(
3899
+ "The knowledge-base tool returned no structured content.",
3900
+ suggestion="Retry, or fall back to `maxc meta search` for metadata lookups.",
3901
+ )
3902
+ return structured
3903
+
3904
+ @staticmethod
3905
+ def _kb_payload(
3906
+ structured: 'dict[str, Any]',
3907
+ *,
3908
+ answer_from: 'tuple[str, ...] | None' = None,
3909
+ nested_citation: bool = False,
3910
+ package: bool = False,
3911
+ ) -> 'dict[str, Any]':
3912
+ """Carry the server's fields through rather than rebuilding them.
3913
+
3914
+ Only two transformations happen here: citations are flattened to a stable
3915
+ ``uri`` key (the server nests it under ``source`` for search and puts it
3916
+ top-level for ask), and an answer string is wrapped so its provenance is
3917
+ explicit. Everything else is passed by reference on purpose — a field the
3918
+ service adds starts appearing in maxc output with no CLI change, which is
3919
+ the behaviour worth having when the upstream schema is not ours to pin.
3920
+ """
3921
+ data = structured.get("data") if isinstance(structured.get("data"), dict) else {}
3922
+ payload: dict[str, Any] = {}
3923
+ if answer_from is not None:
3924
+ text = next(
3925
+ (data[key] for key in answer_from if data.get(key) is not None), None
3926
+ )
3927
+ payload["answer"] = {"query": data.get("query"), "text": text}
3928
+ else:
3929
+ search: dict[str, Any] = {
3930
+ "query": data.get("query"),
3931
+ "matches": [],
3932
+ }
3933
+ if package:
3934
+ search["package"] = data.get("package")
3935
+ raw_items = data.get("results") or data.get("matches") or []
3936
+ matches = []
3937
+ for item in raw_items:
3938
+ if not isinstance(item, dict):
3939
+ continue
3940
+ entry = dict(item)
3941
+ uri = entry.get("uri") or entry.get("url")
3942
+ if uri is None and nested_citation:
3943
+ source = entry.get("source")
3944
+ if isinstance(source, dict):
3945
+ uri = source.get("uri") or source.get("url")
3946
+ elif isinstance(source, str):
3947
+ uri = source
3948
+ if uri is not None:
3949
+ entry["uri"] = uri
3950
+ matches.append(entry)
3951
+ search["matches"] = matches
3952
+ payload["search"] = search
3953
+ raw_citations = structured.get("citations")
3954
+ if isinstance(raw_citations, list):
3955
+ flattened = []
3956
+ for item in raw_citations:
3957
+ if not isinstance(item, dict):
3958
+ continue
3959
+ entry = dict(item)
3960
+ uri = entry.get("uri") or entry.get("url")
3961
+ if uri is not None:
3962
+ entry["uri"] = uri
3963
+ flattened.append(entry)
3964
+ payload["citations"] = flattened
3965
+ payload["pagination"] = {
3966
+ "has_more": bool(structured.get("has_more", False)),
3967
+ "next_cursor": structured.get("next_cursor"),
3968
+ }
3969
+ # `request_id` is the only handle for correlating a metered model call with
3970
+ # a service-side incident, so keep it at a predictable location.
3971
+ payload["request_id"] = structured.get("request_id")
3972
+ return payload
3973
+
3974
+ def kb_ask(
3975
+ self,
3976
+ question: 'str',
3977
+ *,
3978
+ max_docs: 'int | None' = None,
3979
+ region: 'str | None' = None,
3980
+ ) -> 'Envelope':
3981
+ """Answer from retrieved documentation, with citations kept as first-class output.
3982
+
3983
+ The two kb tools return different shapes (ask yields ``data.answer`` plus a
3984
+ top-level ``citations[]``; search yields ``data.results[]`` with the URI nested
3985
+ under ``source``), so each is projected explicitly instead of sharing one guess.
3986
+ """
3987
+ started = monotonic()
3988
+ arguments: dict[str, Any] = {"question": question}
3989
+ if max_docs is not None:
3990
+ arguments["max_docs"] = max_docs
3991
+ effective_region = region or self.config.default_region
3992
+ if effective_region:
3993
+ arguments["region"] = effective_region
3994
+ structured = self._call_kb_tool("maxcompute_kb_ask", arguments)
3995
+ payload = self._kb_payload(
3996
+ structured,
3997
+ answer_from=("answer", "text"),
3998
+ )
3999
+ envelope = self._kb_envelope(
4000
+ "kb.ask",
4001
+ payload,
4002
+ started,
4003
+ effective_region,
4004
+ follow_up="kb.search",
4005
+ warnings=self._kb_warnings(structured),
4006
+ insights=[
4007
+ "The answer text is model-generated from the cited documents; attribute "
4008
+ "product claims to `citations[].uri` rather than restating them as fact.",
4009
+ "An empty or thin citation list means retrieval found little, which is "
4010
+ "not evidence that the behaviour is undocumented.",
4011
+ ],
4012
+ )
4013
+ self.log("kb.ask", envelope.status, envelope.metadata)
4014
+ return envelope
4015
+
4016
+ def kb_search(
4017
+ self,
4018
+ query: 'str',
4019
+ *,
4020
+ limit: 'int' = 5,
4021
+ context_lines: 'int | None' = None,
4022
+ region: 'str | None' = None,
4023
+ ) -> 'Envelope':
4024
+ started = monotonic()
4025
+ arguments: dict[str, Any] = {"query": query, "limit": limit}
4026
+ if context_lines is not None:
4027
+ arguments["before_lines"] = context_lines
4028
+ arguments["after_lines"] = context_lines
4029
+ effective_region = region or self.config.default_region
4030
+ if effective_region:
4031
+ arguments["region"] = effective_region
4032
+ structured = self._call_kb_tool("maxcompute_kb_search", arguments)
4033
+ payload = self._kb_payload(
4034
+ structured,
4035
+ nested_citation=True,
4036
+ package=True,
4037
+ )
4038
+ envelope = self._kb_envelope(
4039
+ "kb.search",
4040
+ payload,
4041
+ started,
4042
+ effective_region,
4043
+ follow_up="kb.ask",
4044
+ warnings=self._kb_warnings(structured),
4045
+ insights=[
4046
+ "Snippets are truncated server-side; open the cited `uri` before quoting "
4047
+ "a passage as complete.",
4048
+ ],
4049
+ )
4050
+ self.log("kb.search", envelope.status, envelope.metadata)
4051
+ return envelope
4052
+
4053
+ @staticmethod
4054
+ def _kb_warnings(structured: 'dict[str, Any]') -> 'list[str]':
4055
+ """Surface retrieval degradation that still answered HTTP 200 and ok=true-looking.
4056
+
4057
+ Negative inference is the specific risk here: an agent reading an empty result
4058
+ as "the platform cannot do this" produces a confident, wrong answer.
4059
+ """
4060
+ warnings = [str(item) for item in (structured.get("warnings") or [])]
4061
+ if structured.get("ok") is False:
4062
+ warnings.append(
4063
+ "The knowledge-base tool reported ok=false; treat this response as unverified."
4064
+ )
4065
+ return warnings
4066
+
4067
+ def _kb_envelope(
4068
+ self,
4069
+ command: 'str',
4070
+ payload: 'dict[str, Any]',
4071
+ started: float,
4072
+ region: 'str | None',
4073
+ *,
4074
+ follow_up: 'str',
4075
+ warnings: 'list[str]',
4076
+ insights: 'list[str]',
4077
+ ) -> 'Envelope':
4078
+ metadata = {
4079
+ "elapsed_ms": int((monotonic() - started) * 1000),
4080
+ "region": region or None,
4081
+ "backend": "mcp",
4082
+ }
4083
+ return Envelope(
4084
+ command=command,
4085
+ status="success",
4086
+ data=payload,
4087
+ metadata=metadata,
4088
+ agent_hints=AgentHints(
4089
+ actions=[action(follow_up, data=payload, metadata=metadata)],
4090
+ warnings=warnings,
4091
+ insights=insights,
4092
+ ),
4093
+ )
4094
+
4095
+ def _call_kb_tool(self, tool: 'str', arguments: 'dict[str, Any]') -> 'dict[str, Any]':
4096
+ from .backend.mcp import McpError
4097
+
4098
+ client = self._mcp_client()
4099
+ try:
4100
+ result = client.call_tool(tool, arguments)
4101
+ except McpError as exc:
4102
+ raise BackendConnectionError(
4103
+ str(exc),
4104
+ suggestion=(
4105
+ "Confirm the MCP endpoint is reachable for your region, then retry. "
4106
+ "Do not conclude from this failure that the documentation lacks an answer."
4107
+ ),
4108
+ ) from exc
4109
+ # A tool-level error still answers HTTP 200, so it must not read as success.
4110
+ if result.get("isError"):
4111
+ content = result.get("content") or []
4112
+ detail = ""
4113
+ if content and isinstance(content[0], dict):
4114
+ detail = str(content[0].get("text") or "")
4115
+ raise BackendConnectionError(
4116
+ "The knowledge-base tool failed: "
4117
+ + (detail[:300] or "no detail returned")
4118
+ )
4119
+ return self._kb_structured(result)
4120
+
3839
4121
  def meta_list_schemas(self, *, project: 'str | None' = None) -> 'Envelope':
3840
4122
  """List all schemas in a project."""
3841
4123
  target_project = project or self.config.default_project
@@ -5677,6 +5959,9 @@ class MaxCApp:
5677
5959
  "remote_jobs": getattr(self.backend, "supports_remote_jobs", True) if self.backend else True,
5678
5960
  "cost_check": getattr(self.backend, "supports_cost_check", True) if self.backend else True,
5679
5961
  "lineage": False, # Always false for current ODPS backend
5962
+ # Reported from configuration only; probing the MCP endpoint would make
5963
+ # this local command reach the network.
5964
+ "knowledge_base": bool(self.config.mcp.enabled),
5680
5965
  }
5681
5966
 
5682
5967
  # Keep agent.context strictly local. Report Catalog search capability
@@ -66,8 +66,12 @@ class ResolvedAuthConnection:
66
66
  _MINIMUM_PYODPS = "0.12.0"
67
67
 
68
68
  def create_client(self):
69
+ from .enterprise_tls import configure_enterprise_tls_env
69
70
  from .odps_runtime import configure_user_agent
70
71
 
72
+ # requests resolves SSL_CERT_FILE when a Session is constructed, so
73
+ # this must run before ODPS builds its REST and tunnel clients.
74
+ configure_enterprise_tls_env()
71
75
  configure_user_agent()
72
76
  try:
73
77
  from odps import ODPS
@@ -0,0 +1,320 @@
1
+ """MaxCompute Remote MCP access: token minting and a stateless JSON-RPC client.
2
+
3
+ Two responsibilities live here, deliberately separated because they fail differently:
4
+
5
+ ``CatalogMcpTokenProvider``
6
+ Trades the caller's *already resolved* MaxCompute credentials for a short-lived
7
+ ``mcpc_`` bearer by issuing an authenticated CatalogAPI call. No browser, no OAuth
8
+ redirect, no refresh token: renewal means calling the mint endpoint again. Reference
9
+ implementation: ``remote_auth.py`` in aliyun/alibabacloud-maxcompute-mcp-server.
10
+
11
+ ``McpHttpClient``
12
+ Minimal Streamable-HTTP JSON-RPC client for ``tools/list`` and ``tools/call``. The
13
+ deployed server is stateless today (it returns no ``mcp-session-id``), so nothing here
14
+ caches a session; see ``docs/mcp-kb-access-research.md`` §6.2 before assuming that holds.
15
+
16
+ Only stdlib HTTP is used, so this module adds no runtime dependency.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import threading
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from typing import Any, Callable
27
+
28
+ # --- CatalogAPI token issuance ------------------------------------------------
29
+ # Verified against the live service on 2026-09-20.
30
+
31
+ _MCP_ACCESS_TOKEN_PATH = "/api/catalog/v1alpha/mcpAccessToken"
32
+ _EXPECTED_SCOPES = ["maxcompute:read", "maxcompute:sql"]
33
+ _TOKEN_LIFETIME_SECONDS = 300
34
+ # Renew this long before real expiry so a request never starts on a doomed token.
35
+ _EXPIRY_SKEW_SECONDS = 60.0
36
+ _MAX_TOKEN_CHARS = 16 * 1024
37
+ _TOKEN_PREFIX = "mcpc_"
38
+ # Tea sends a bodyless stream as application/octet-stream and CatalogAPI signs the
39
+ # received Content-Type into its canonical string. Declaring anything else here
40
+ # produces a signature error that reads like an authentication failure.
41
+ _EMPTY_BODY_CONTENT_TYPE = "application/octet-stream"
42
+ _MINT_TIMEOUT_SECONDS = 10.0
43
+
44
+ DEFAULT_PROTOCOL_VERSION = "2025-06-18"
45
+
46
+
47
+ class McpError(RuntimeError):
48
+ """Base class for MCP access failures safe to show to a user."""
49
+
50
+
51
+ class TokenUnavailableError(McpError):
52
+ """The bearer could not be minted, usually missing or unusable credentials."""
53
+
54
+
55
+ class McpRequestError(McpError):
56
+ """The MCP endpoint rejected or failed a JSON-RPC call."""
57
+
58
+ def __init__(self, message: str, *, status: int | None = None,
59
+ request_id: str | None = None) -> None:
60
+ super().__init__(message)
61
+ self.status = status
62
+ self.request_id = request_id
63
+
64
+
65
+ def _strip_api_suffix(endpoint: str) -> str:
66
+ text = (endpoint or "").rstrip("/")
67
+ return text[: -len("/api")] if text.endswith("/api") else text
68
+
69
+
70
+ class CatalogMcpTokenProvider:
71
+ """Mint and cache CatalogAPI MCP bearers, single-flighting renewals.
72
+
73
+ ``mint`` is injected rather than hard-wired so tests never touch the network and
74
+ so the signing path stays whatever pyodps already does correctly.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ mint: Callable[[], dict[str, Any]],
80
+ *,
81
+ expiry_skew: float = _EXPIRY_SKEW_SECONDS,
82
+ clock: Callable[[], float] = time.monotonic,
83
+ ) -> None:
84
+ if not 0 <= expiry_skew < _TOKEN_LIFETIME_SECONDS:
85
+ raise ValueError("expiry skew must be within the token lifetime")
86
+ self._mint = mint
87
+ self._expiry_skew = expiry_skew
88
+ self._clock = clock
89
+ self._token = ""
90
+ self._expires_at = 0.0
91
+ self._lock = threading.Lock()
92
+
93
+ def get(self) -> str:
94
+ """Return a usable bearer, minting at most once for concurrent callers."""
95
+ with self._lock:
96
+ if self._token and self._clock() + self._expiry_skew < self._expires_at:
97
+ return self._token
98
+ value = self._parse(self._mint())
99
+ self._token = value
100
+ self._expires_at = self._clock() + _TOKEN_LIFETIME_SECONDS
101
+ return value
102
+
103
+ def invalidate(self) -> None:
104
+ """Drop the cached bearer so the next ``get`` re-mints."""
105
+ with self._lock:
106
+ self._token = ""
107
+ self._expires_at = 0.0
108
+
109
+ @staticmethod
110
+ def _parse(payload: Any) -> str:
111
+ """Validate every field the reference implementation asserts, then return the token.
112
+
113
+ Strictness is intentional: a silently different lifetime or scope would turn a
114
+ working call into an intermittent 401 that is expensive to diagnose from a CLI.
115
+ """
116
+ if not isinstance(payload, dict):
117
+ raise TokenUnavailableError("MCP token response was not a JSON object.")
118
+ missing = {"accessToken", "tokenType", "expiresIn", "scope"} - set(payload)
119
+ if missing:
120
+ raise TokenUnavailableError(
121
+ "MCP token response missing field(s): " + ", ".join(sorted(missing)) + "."
122
+ )
123
+ # A refresh token here means the service changed contract; caching one would
124
+ # imply a renewal path this provider deliberately does not have.
125
+ if any("refresh" in key.lower() for key in payload):
126
+ raise TokenUnavailableError(
127
+ "MCP token response carried a refresh token, which this client does not support."
128
+ )
129
+ value = payload.get("accessToken")
130
+ if (
131
+ not isinstance(value, str)
132
+ or not value.startswith(_TOKEN_PREFIX)
133
+ or len(value) <= len(_TOKEN_PREFIX)
134
+ or len(value) > _MAX_TOKEN_CHARS
135
+ ):
136
+ raise TokenUnavailableError("MCP token response carried an unexpected access token.")
137
+ if payload.get("tokenType") != "Bearer":
138
+ raise TokenUnavailableError("MCP token response carried an unexpected tokenType.")
139
+ expires_in = payload.get("expiresIn")
140
+ if isinstance(expires_in, bool) or expires_in != _TOKEN_LIFETIME_SECONDS:
141
+ raise TokenUnavailableError(
142
+ f"MCP token lifetime {expires_in!r} differs from the expected "
143
+ f"{_TOKEN_LIFETIME_SECONDS}s; caching assumptions would be invalid."
144
+ )
145
+ if payload.get("scope") != _EXPECTED_SCOPES:
146
+ raise TokenUnavailableError("MCP token response carried an unexpected scope.")
147
+ return value
148
+
149
+
150
+ def build_catalog_mint(catalog_rest: Any, catalog_endpoint: str) -> Callable[[], dict[str, Any]]:
151
+ """Return a callable that mints a bearer through an authenticated Catalog rest client.
152
+
153
+ Reuses the same signed transport as catalog search, so whatever credential chain
154
+ ``auth whoami`` resolved is what authorizes the MCP call.
155
+ """
156
+ base = _strip_api_suffix(catalog_endpoint)
157
+ if not base.startswith(("https://", "http://")):
158
+ raise TokenUnavailableError("Catalog endpoint is not an HTTP(S) URL.")
159
+ url = base + _MCP_ACCESS_TOKEN_PATH
160
+
161
+ def mint() -> dict[str, Any]:
162
+ try:
163
+ response = catalog_rest.request(
164
+ url,
165
+ "post",
166
+ data=b"",
167
+ headers={"content-type": _EMPTY_BODY_CONTENT_TYPE},
168
+ timeout=_MINT_TIMEOUT_SECONDS,
169
+ )
170
+ except Exception as exc: # noqa: BLE001 -- credential/transport SDK errors vary
171
+ raise TokenUnavailableError(
172
+ "Could not request an MCP access token from CatalogAPI. "
173
+ "Check that MaxCompute credentials resolve (try `maxc auth whoami --json`)."
174
+ ) from exc
175
+ body = getattr(response, "content", None)
176
+ if body is None:
177
+ reader = getattr(response, "read", None)
178
+ body = reader() if callable(reader) else response
179
+ if isinstance(body, bytes):
180
+ body = body.decode("utf-8", "replace")
181
+ if isinstance(body, dict):
182
+ # Some transports hand back an already-decoded mapping; re-encoding keeps
183
+ # the parse path single rather than branching twice.
184
+ return body
185
+ try:
186
+ return json.loads(body)
187
+ except (TypeError, ValueError) as exc:
188
+ raise TokenUnavailableError("CatalogAPI returned a non-JSON MCP token response.") from exc
189
+
190
+ return mint
191
+
192
+
193
+ # --- Stateless JSON-RPC over Streamable HTTP ---------------------------------
194
+
195
+ class McpHttpClient:
196
+ """Call MCP tools over Streamable HTTP without assuming a server session."""
197
+
198
+ def __init__(
199
+ self,
200
+ url: str,
201
+ token_provider: CatalogMcpTokenProvider,
202
+ *,
203
+ timeout: float = 120.0,
204
+ client_name: str = "maxc-cli",
205
+ client_version: str = "0",
206
+ opener: Any = None,
207
+ ) -> None:
208
+ if not url.startswith("https://"):
209
+ # The bearer is a credential; never send it over plaintext.
210
+ raise ValueError("MCP endpoint must be an https:// URL.")
211
+ self._url = url
212
+ self._tokens = token_provider
213
+ self._timeout = timeout
214
+ self._client = (client_name, client_version)
215
+ self._opener = opener or urllib.request.build_opener()
216
+ self._next_id = 0
217
+
218
+ @property
219
+ def url(self) -> str:
220
+ """The endpoint this client talks to, for surfacing in startup output."""
221
+ return self._url
222
+
223
+ def list_tools(self) -> list[dict[str, Any]]:
224
+ result = self._request("tools/list", {})
225
+ tools = result.get("tools") if isinstance(result, dict) else None
226
+ return list(tools or [])
227
+
228
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
229
+ """Invoke one tool, retrying exactly once after a token expiry."""
230
+ payload = {"name": name, "arguments": arguments}
231
+ try:
232
+ return self._request("tools/call", payload)
233
+ except McpRequestError as exc:
234
+ if exc.status != 401:
235
+ raise
236
+ # Observed behaviour: an expired bearer answers 401 "invalid token".
237
+ self._tokens.invalidate()
238
+ return self._request("tools/call", payload)
239
+
240
+ def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
241
+ self._next_id += 1
242
+ envelope = {"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}
243
+ raw = self._post(json.dumps(envelope).encode("utf-8"))
244
+ decoded = self._decode(raw)
245
+ if "error" in decoded:
246
+ err = decoded["error"] or {}
247
+ raise McpRequestError(
248
+ f"MCP {method} failed: {err.get('message', 'unknown error')}",
249
+ request_id=str(err.get("code")) if err.get("code") is not None else None,
250
+ )
251
+ result = decoded.get("result")
252
+ if not isinstance(result, dict):
253
+ raise McpRequestError(f"MCP {method} returned no result object.")
254
+ return result
255
+
256
+ def _post(self, body: bytes, *, retry_on_expiry: bool = True) -> bytes:
257
+ token = self._tokens.get()
258
+ request = urllib.request.Request(self._url, data=body, method="POST")
259
+ request.add_header("Content-Type", "application/json")
260
+ # Streamable HTTP servers may answer either shape; accepting both keeps the
261
+ # client working if the gateway switches to event-stream responses.
262
+ request.add_header("Accept", "application/json, text/event-stream")
263
+ request.add_header("MCP-Protocol-Version", DEFAULT_PROTOCOL_VERSION)
264
+ request.add_header("Authorization", f"Bearer {token}")
265
+ try:
266
+ with self._opener.open(request, timeout=self._timeout) as response:
267
+ return response.read()
268
+ except urllib.error.HTTPError as exc:
269
+ detail = b""
270
+ try:
271
+ detail = exc.read()
272
+ except Exception: # noqa: BLE001 -- body is best-effort context only
273
+ pass
274
+ text = detail.decode("utf-8", "replace").strip()
275
+ if exc.code == 401:
276
+ raise McpRequestError(
277
+ "MCP endpoint rejected the access token.", status=401
278
+ ) from exc
279
+ raise McpRequestError(
280
+ f"MCP endpoint returned HTTP {exc.code}" + (f": {text[:200]}" if text else ""),
281
+ status=exc.code,
282
+ ) from exc
283
+ except urllib.error.URLError as exc:
284
+ raise McpRequestError(f"Could not reach the MCP endpoint: {exc.reason}") from exc
285
+
286
+ @staticmethod
287
+ def _decode(raw: bytes) -> dict[str, Any]:
288
+ """Accept plain JSON or an SSE body carrying a single JSON data frame."""
289
+ text = (raw or b"").decode("utf-8", "replace").strip()
290
+ if not text:
291
+ raise McpRequestError("MCP endpoint returned an empty response body.")
292
+ if not text.lstrip().startswith("{"):
293
+ frames = [
294
+ line[len("data:"):].strip()
295
+ for line in text.splitlines()
296
+ if line.startswith("data:")
297
+ ]
298
+ for frame in reversed(frames):
299
+ if frame and frame != "[DONE]":
300
+ text = frame
301
+ break
302
+ try:
303
+ decoded = json.loads(text)
304
+ except ValueError as exc:
305
+ raise McpRequestError("MCP endpoint returned a non-JSON response.") from exc
306
+ if not isinstance(decoded, dict):
307
+ raise McpRequestError("MCP endpoint returned an unexpected response shape.")
308
+ return decoded
309
+
310
+
311
+ def default_endpoint(region: str | None, *, site: str = "CN") -> str:
312
+ """Build the public MCP host for a region, falling back to dynamic routing."""
313
+ if not region:
314
+ host = "mcp.maxcompute.aliyun.com" if site.upper() != "INTL" else "mcp-intl.maxcompute.aliyun.com"
315
+ return f"https://{host}/mcp"
316
+ cleaned = str(region).strip().lower()
317
+ if not cleaned.replace("-", "").isalnum():
318
+ raise ValueError("region must be an alphanumeric region id")
319
+ prefix = "mcp" if site.upper() != "INTL" else "mcp-intl"
320
+ return f"https://{prefix}.{cleaned}.maxcompute.aliyun.com/mcp"