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.
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/PKG-INFO +1 -1
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/__init__.py +1 -1
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/_samples.py +16 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/app.py +285 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/auth_providers.py +4 -0
- maxc_cli-0.7.0/src/maxc_cli/backend/mcp.py +320 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/odps.py +83 -36
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/cli.py +254 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/config.py +45 -0
- maxc_cli-0.7.0/src/maxc_cli/enterprise_tls.py +168 -0
- maxc_cli-0.7.0/src/maxc_cli/mcp_serve.py +264 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/models.py +16 -1
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/oauth.py +25 -2
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/output.py +37 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/SKILL.md +37 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/command-patterns.md +2 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/PKG-INFO +1 -1
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/SOURCES.txt +7 -0
- maxc_cli-0.7.0/tests/test_backend_mcp.py +419 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_build_release_archive_compat.py +38 -0
- maxc_cli-0.7.0/tests/test_enterprise_tls.py +139 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_installer_contracts.py +130 -8
- maxc_cli-0.7.0/tests/test_kb_commands.py +497 -0
- maxc_cli-0.7.0/tests/test_mcp_serve.py +352 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_oauth.py +113 -1
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_query_result_csv_fallback.py +120 -2
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/MANIFEST.in +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/README.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/pyproject.toml +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/scripts/pyinstaller_entry.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/scripts/regression_test.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/setup.cfg +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/setup.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/__main__.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/agent_platforms.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/audit.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/auth_continuation.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/__init__.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/auth.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/catalog.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/data.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/job.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/meta.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/query.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/backend/semantic.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/cache.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/catalog_bootstrap.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/exceptions.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/help_format.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/helpers.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/job_ids.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/masking.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/odps_runtime.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/proxy_auth.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/semantic.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/semantic_management.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/setting_parser.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/agents/openai.yaml +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/json-output-format.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/partition-guide.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/red-lines.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/semantic-packages.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/setup-install.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/state_permissions.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/store.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli/utils.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/entry_points.txt +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/requires.txt +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/src/maxc_cli.egg-info/top_level.txt +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_hints_and_cli.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_platforms.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_skill_commands.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_agent_skill_commands_context.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_ai_native_contract_regressions.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_auth_logout.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_auth.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_data.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_data_serialization.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_backend_meta.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_build_release_script.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cache.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_catalog.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_catalog_bootstrap.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_arg_validation.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_mock.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_cli_query_parse_and_sanitize.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_compat.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_config_atomic_write.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_e2e_smoke.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_effective_hints_contract.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_envelope_shape.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_error_self_correction.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_error_translation.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_exit_codes.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_external_auth.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_flag_hoist.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_help_format.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_help_version_e2e.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_helpers.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_helpers_csv.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_integration.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_integration_real.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_job_improvements.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_job_store_durability.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_manifest_runtime_contract.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_masking.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_meta_schema_and_partition_cols.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_odps_runtime.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_output_action_safety.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_output_format_contract.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_packaging_metadata.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_phase1_improvements.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_proxy_auth.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_pyinstaller_bundle.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_python39_compat.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_query_auto_promote.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_management.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_scope.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_semantic_transport.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_setting_parser.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_cli_consistency.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_eval.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_skill_renderer.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_startup_imports.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_state_permissions.py +0 -0
- {maxc_cli-0.6.0 → maxc_cli-0.7.0}/tests/test_state_portability.py +0 -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"
|