keboola-cli 0.63.4__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.
- keboola_agent_cli/__init__.py +34 -0
- keboola_agent_cli/__main__.py +5 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/index.html +50 -0
- keboola_agent_cli/ai_client.py +83 -0
- keboola_agent_cli/auto_update.py +550 -0
- keboola_agent_cli/changelog.py +1198 -0
- keboola_agent_cli/cli.py +448 -0
- keboola_agent_cli/client.py +3422 -0
- keboola_agent_cli/commands/__init__.py +0 -0
- keboola_agent_cli/commands/_data_app_git.py +343 -0
- keboola_agent_cli/commands/_helpers.py +377 -0
- keboola_agent_cli/commands/_metadata_input.py +49 -0
- keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
- keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
- keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
- keboola_agent_cli/commands/agent.py +968 -0
- keboola_agent_cli/commands/branch.py +423 -0
- keboola_agent_cli/commands/changelog.py +168 -0
- keboola_agent_cli/commands/component.py +216 -0
- keboola_agent_cli/commands/config.py +2442 -0
- keboola_agent_cli/commands/context.py +1481 -0
- keboola_agent_cli/commands/data_app.py +1279 -0
- keboola_agent_cli/commands/dev_portal.py +584 -0
- keboola_agent_cli/commands/doctor.py +37 -0
- keboola_agent_cli/commands/encrypt.py +145 -0
- keboola_agent_cli/commands/feature.py +311 -0
- keboola_agent_cli/commands/flow.py +948 -0
- keboola_agent_cli/commands/http_client.py +157 -0
- keboola_agent_cli/commands/init.py +279 -0
- keboola_agent_cli/commands/job.py +661 -0
- keboola_agent_cli/commands/kai.py +301 -0
- keboola_agent_cli/commands/lineage.py +1464 -0
- keboola_agent_cli/commands/org.py +292 -0
- keboola_agent_cli/commands/permissions.py +360 -0
- keboola_agent_cli/commands/project.py +1192 -0
- keboola_agent_cli/commands/repl.py +243 -0
- keboola_agent_cli/commands/schedule.py +340 -0
- keboola_agent_cli/commands/search.py +178 -0
- keboola_agent_cli/commands/semantic_layer.py +939 -0
- keboola_agent_cli/commands/serve.py +272 -0
- keboola_agent_cli/commands/sharing.py +340 -0
- keboola_agent_cli/commands/storage.py +2630 -0
- keboola_agent_cli/commands/stream.py +266 -0
- keboola_agent_cli/commands/sync.py +1277 -0
- keboola_agent_cli/commands/tool.py +206 -0
- keboola_agent_cli/commands/version.py +186 -0
- keboola_agent_cli/commands/workspace.py +635 -0
- keboola_agent_cli/config_store.py +582 -0
- keboola_agent_cli/constants.py +528 -0
- keboola_agent_cli/data_science_client.py +342 -0
- keboola_agent_cli/dev_portal_client.py +323 -0
- keboola_agent_cli/errors.py +248 -0
- keboola_agent_cli/http_base.py +315 -0
- keboola_agent_cli/json_utils.py +126 -0
- keboola_agent_cli/lib.py +536 -0
- keboola_agent_cli/manage_client.py +324 -0
- keboola_agent_cli/metastore_client.py +214 -0
- keboola_agent_cli/models.py +427 -0
- keboola_agent_cli/output.py +1084 -0
- keboola_agent_cli/permissions.py +469 -0
- keboola_agent_cli/py.typed +3 -0
- keboola_agent_cli/result_models.py +271 -0
- keboola_agent_cli/server/__init__.py +34 -0
- keboola_agent_cli/server/agent_runner.py +1289 -0
- keboola_agent_cli/server/agents_store.py +325 -0
- keboola_agent_cli/server/app.py +764 -0
- keboola_agent_cli/server/auth.py +117 -0
- keboola_agent_cli/server/dependencies.py +149 -0
- keboola_agent_cli/server/pricing.py +303 -0
- keboola_agent_cli/server/routers/__init__.py +1 -0
- keboola_agent_cli/server/routers/agents.py +616 -0
- keboola_agent_cli/server/routers/ai_chat.py +129 -0
- keboola_agent_cli/server/routers/branches.py +133 -0
- keboola_agent_cli/server/routers/components.py +48 -0
- keboola_agent_cli/server/routers/configs.py +507 -0
- keboola_agent_cli/server/routers/data_apps.py +384 -0
- keboola_agent_cli/server/routers/dev_portal.py +67 -0
- keboola_agent_cli/server/routers/encrypt.py +35 -0
- keboola_agent_cli/server/routers/feature.py +179 -0
- keboola_agent_cli/server/routers/flows.py +204 -0
- keboola_agent_cli/server/routers/health.py +53 -0
- keboola_agent_cli/server/routers/jobs.py +175 -0
- keboola_agent_cli/server/routers/kai.py +80 -0
- keboola_agent_cli/server/routers/lineage.py +226 -0
- keboola_agent_cli/server/routers/mcp.py +70 -0
- keboola_agent_cli/server/routers/members.py +170 -0
- keboola_agent_cli/server/routers/org.py +96 -0
- keboola_agent_cli/server/routers/projects.py +106 -0
- keboola_agent_cli/server/routers/schedules.py +54 -0
- keboola_agent_cli/server/routers/search.py +30 -0
- keboola_agent_cli/server/routers/semantic_layer.py +650 -0
- keboola_agent_cli/server/routers/sharing.py +86 -0
- keboola_agent_cli/server/routers/storage.py +574 -0
- keboola_agent_cli/server/routers/stream.py +100 -0
- keboola_agent_cli/server/routers/workspaces.py +302 -0
- keboola_agent_cli/server/run_broadcaster.py +329 -0
- keboola_agent_cli/server/sse.py +25 -0
- keboola_agent_cli/services/__init__.py +0 -0
- keboola_agent_cli/services/_encryption.py +217 -0
- keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
- keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
- keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
- keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
- keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
- keboola_agent_cli/services/_sync_bindings.py +456 -0
- keboola_agent_cli/services/_sync_branch.py +191 -0
- keboola_agent_cli/services/_sync_bulk.py +228 -0
- keboola_agent_cli/services/_sync_clone.py +163 -0
- keboola_agent_cli/services/_sync_models.py +97 -0
- keboola_agent_cli/services/_sync_push_ops.py +369 -0
- keboola_agent_cli/services/_sync_storage.py +376 -0
- keboola_agent_cli/services/_sync_writeback.py +167 -0
- keboola_agent_cli/services/agent_service.py +458 -0
- keboola_agent_cli/services/base.py +175 -0
- keboola_agent_cli/services/branch_service.py +588 -0
- keboola_agent_cli/services/component_service.py +694 -0
- keboola_agent_cli/services/config_service.py +2099 -0
- keboola_agent_cli/services/data_app_git_service.py +224 -0
- keboola_agent_cli/services/data_app_service.py +2082 -0
- keboola_agent_cli/services/deep_lineage_service.py +1322 -0
- keboola_agent_cli/services/dev_portal_service.py +345 -0
- keboola_agent_cli/services/doctor_service.py +445 -0
- keboola_agent_cli/services/encrypt_service.py +87 -0
- keboola_agent_cli/services/feature_service.py +268 -0
- keboola_agent_cli/services/flow_service.py +769 -0
- keboola_agent_cli/services/flow_validation.py +188 -0
- keboola_agent_cli/services/http_forwarder_service.py +236 -0
- keboola_agent_cli/services/job_idempotency_store.py +285 -0
- keboola_agent_cli/services/job_service.py +797 -0
- keboola_agent_cli/services/kai_service.py +367 -0
- keboola_agent_cli/services/lineage_service.py +274 -0
- keboola_agent_cli/services/mcp_service.py +1498 -0
- keboola_agent_cli/services/mcp_transport.py +259 -0
- keboola_agent_cli/services/member_service.py +593 -0
- keboola_agent_cli/services/org_service.py +619 -0
- keboola_agent_cli/services/project_service.py +947 -0
- keboola_agent_cli/services/repo_validate_service.py +767 -0
- keboola_agent_cli/services/schedule_service.py +731 -0
- keboola_agent_cli/services/search_service.py +331 -0
- keboola_agent_cli/services/semantic_layer_service.py +1497 -0
- keboola_agent_cli/services/sharing_service.py +307 -0
- keboola_agent_cli/services/storage_service.py +2524 -0
- keboola_agent_cli/services/stream_service.py +395 -0
- keboola_agent_cli/services/sync_service.py +2244 -0
- keboola_agent_cli/services/variables_service.py +447 -0
- keboola_agent_cli/services/version_service.py +1038 -0
- keboola_agent_cli/services/workspace_service.py +1103 -0
- keboola_agent_cli/stream_client.py +217 -0
- keboola_agent_cli/sync/__init__.py +1 -0
- keboola_agent_cli/sync/branch_mapping.py +174 -0
- keboola_agent_cli/sync/clone.py +211 -0
- keboola_agent_cli/sync/code_extraction.py +655 -0
- keboola_agent_cli/sync/config_format.py +290 -0
- keboola_agent_cli/sync/diff_engine.py +566 -0
- keboola_agent_cli/sync/git_utils.py +93 -0
- keboola_agent_cli/sync/manifest.py +162 -0
- keboola_agent_cli/sync/naming.py +90 -0
- keboola_agent_cli/sync/secrets.py +62 -0
- keboola_agent_cli/sync/sql_split.py +134 -0
- keboola_cli-0.63.4.dist-info/METADATA +308 -0
- keboola_cli-0.63.4.dist-info/RECORD +306 -0
- keboola_cli-0.63.4.dist-info/WHEEL +4 -0
- keboola_cli-0.63.4.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
"""MCP integration service - wraps keboola-mcp-server.
|
|
2
|
+
|
|
3
|
+
Provides multi-project tool listing and execution via MCP protocol.
|
|
4
|
+
Read tools run across ALL projects in parallel; write tools target a single project.
|
|
5
|
+
|
|
6
|
+
Supports two transport modes:
|
|
7
|
+
- HTTP (default): Persistent server with per-request credentials via headers.
|
|
8
|
+
One server serves all projects. Fastest for repeated calls.
|
|
9
|
+
- stdio: Subprocess per call. Fallback when HTTP transport is unavailable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from contextlib import AsyncExitStack
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from mcp import ClientSession, StdioServerParameters
|
|
22
|
+
from mcp.client.stdio import stdio_client
|
|
23
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
24
|
+
|
|
25
|
+
from ..constants import (
|
|
26
|
+
DEFAULT_MCP_INIT_TIMEOUT,
|
|
27
|
+
DEFAULT_MCP_MAX_SESSIONS,
|
|
28
|
+
DEFAULT_MCP_TOOL_TIMEOUT,
|
|
29
|
+
DEFAULT_MCP_TRANSPORT,
|
|
30
|
+
ENV_CONVERSATION_ID,
|
|
31
|
+
ENV_MCP_INIT_TIMEOUT,
|
|
32
|
+
ENV_MCP_MAX_SESSIONS,
|
|
33
|
+
ENV_MCP_TOOL_TIMEOUT,
|
|
34
|
+
ENV_MCP_TRANSPORT,
|
|
35
|
+
MCP_UV_PRERELEASE_FLAG,
|
|
36
|
+
)
|
|
37
|
+
from ..errors import ConfigError
|
|
38
|
+
from ..models import ProjectConfig
|
|
39
|
+
from ..permissions import PermissionEngine
|
|
40
|
+
from .base import BaseService
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_tool_timeout() -> int:
|
|
46
|
+
"""Get MCP tool timeout (seconds), reading env var at call time."""
|
|
47
|
+
return int(os.environ.get(ENV_MCP_TOOL_TIMEOUT, DEFAULT_MCP_TOOL_TIMEOUT))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_init_timeout() -> int:
|
|
51
|
+
"""Get MCP init timeout (seconds), reading env var at call time."""
|
|
52
|
+
return int(os.environ.get(ENV_MCP_INIT_TIMEOUT, DEFAULT_MCP_INIT_TIMEOUT))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_max_sessions() -> int:
|
|
56
|
+
"""Get max concurrent MCP sessions, reading env var at call time."""
|
|
57
|
+
return int(os.environ.get(ENV_MCP_MAX_SESSIONS, DEFAULT_MCP_MAX_SESSIONS))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _semaphored(sem: asyncio.Semaphore, coro: Any) -> Any:
|
|
61
|
+
"""Run a coroutine under a semaphore to limit concurrency."""
|
|
62
|
+
async with sem:
|
|
63
|
+
return await coro
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Prefixes that indicate write/mutating tools
|
|
67
|
+
WRITE_PREFIXES = (
|
|
68
|
+
"create_",
|
|
69
|
+
"update_",
|
|
70
|
+
"delete_",
|
|
71
|
+
"add_",
|
|
72
|
+
"remove_",
|
|
73
|
+
"set_",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Tools that auto-expand when a required param is missing.
|
|
77
|
+
# Maps tool_name -> config dict. When the param is absent from user input,
|
|
78
|
+
# the resolve_tool is called first to collect IDs, then the target tool
|
|
79
|
+
# is called once with all resolved IDs as an array.
|
|
80
|
+
AUTO_EXPAND_TOOLS = {
|
|
81
|
+
"get_tables": {
|
|
82
|
+
"param": "bucket_ids",
|
|
83
|
+
"resolve_tool": "get_buckets",
|
|
84
|
+
"resolve_key": "id",
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_write_tool(tool_name: str) -> bool:
|
|
90
|
+
"""Determine if a tool name indicates a write/mutating operation."""
|
|
91
|
+
return tool_name.startswith(WRITE_PREFIXES)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_mcp_server_command() -> list[str] | None:
|
|
95
|
+
"""Detect the best way to run keboola-mcp-server.
|
|
96
|
+
|
|
97
|
+
Checks in order of speed (fastest first):
|
|
98
|
+
1. keboola_mcp_server (local install or uv tool, ~1s startup)
|
|
99
|
+
2. python -m keboola_mcp_server (installed in current env)
|
|
100
|
+
3. uvx keboola_mcp_server (cached version, ~1s cached / ~4.5s uncached)
|
|
101
|
+
|
|
102
|
+
Note: We intentionally do NOT use @latest with uvx because it forces
|
|
103
|
+
a PyPI check on every invocation (~25s penalty). The cached / pinned
|
|
104
|
+
version is used at spawn time. Freshness is maintained out-of-band by
|
|
105
|
+
the auto-update flow (`auto_update.maybe_auto_update`, since v0.30.1)
|
|
106
|
+
which runs at kbagent startup and bumps the local MCP install through
|
|
107
|
+
`uv tool upgrade` / `pip install -U` / `uvx --refresh` depending on
|
|
108
|
+
the detected install method. Manual override: `kbagent update`.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of command parts, or None if no method is available.
|
|
112
|
+
"""
|
|
113
|
+
# 1. Local install or uv tool install (fastest: ~1s)
|
|
114
|
+
if shutil.which("keboola_mcp_server"):
|
|
115
|
+
return ["keboola_mcp_server"]
|
|
116
|
+
# 2. python -m (if installed in current env)
|
|
117
|
+
if shutil.which("python"):
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["python", "-c", "import keboola_mcp_server"],
|
|
120
|
+
capture_output=True,
|
|
121
|
+
timeout=5,
|
|
122
|
+
)
|
|
123
|
+
if result.returncode == 0:
|
|
124
|
+
return ["python", "-m", "keboola_mcp_server"]
|
|
125
|
+
# 3. uvx WITHOUT @latest (uses cached version: ~1s cached / ~4.5s uncached)
|
|
126
|
+
# No --prerelease=allow: pre-releases can pull broken dependency chains
|
|
127
|
+
# (e.g. FastMCP 2.14.5 with broken fakeredis). Stable releases only.
|
|
128
|
+
if shutil.which("uvx"):
|
|
129
|
+
return ["uvx", "keboola_mcp_server"]
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ensure_mcp_installed() -> dict[str, Any]:
|
|
134
|
+
"""Ensure keboola-mcp-server is installed as a fast local binary.
|
|
135
|
+
|
|
136
|
+
If the binary is not directly available but uv is present, runs
|
|
137
|
+
`uv tool install` to create a permanent binary in ~/.local/bin/.
|
|
138
|
+
This eliminates the uvx per-call overhead (~0.2-4.5s).
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict with status info: method, installed (bool), message.
|
|
142
|
+
"""
|
|
143
|
+
# Already available as direct binary
|
|
144
|
+
if shutil.which("keboola_mcp_server"):
|
|
145
|
+
return {
|
|
146
|
+
"method": "binary",
|
|
147
|
+
"installed": False,
|
|
148
|
+
"message": "keboola_mcp_server already available in PATH",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Already available as python module
|
|
152
|
+
if shutil.which("python"):
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
["python", "-c", "import keboola_mcp_server"],
|
|
155
|
+
capture_output=True,
|
|
156
|
+
timeout=5,
|
|
157
|
+
)
|
|
158
|
+
if result.returncode == 0:
|
|
159
|
+
return {
|
|
160
|
+
"method": "python_module",
|
|
161
|
+
"installed": False,
|
|
162
|
+
"message": "keboola_mcp_server available as Python module",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Try uv tool install (creates permanent binary, ~1s startup vs ~4.5s uvx)
|
|
166
|
+
if shutil.which("uv"):
|
|
167
|
+
try:
|
|
168
|
+
# --prerelease=allow is mandatory (issue #324): keboola-mcp-server
|
|
169
|
+
# pins a pre-release-only transitive dep (toon-format~=0.9.0b1).
|
|
170
|
+
# Without the flag uv backtracks to the stale v1.32.0 and exits 0,
|
|
171
|
+
# so `doctor --fix` would "succeed" yet install an outdated server.
|
|
172
|
+
result = subprocess.run(
|
|
173
|
+
["uv", "tool", "install", MCP_UV_PRERELEASE_FLAG, "keboola-mcp-server"],
|
|
174
|
+
capture_output=True,
|
|
175
|
+
text=True,
|
|
176
|
+
timeout=120,
|
|
177
|
+
)
|
|
178
|
+
if result.returncode == 0:
|
|
179
|
+
return {
|
|
180
|
+
"method": "uv_tool_install",
|
|
181
|
+
"installed": True,
|
|
182
|
+
"message": "Installed keboola-mcp-server via uv tool install",
|
|
183
|
+
}
|
|
184
|
+
# If already installed, uv tool install returns error
|
|
185
|
+
if "already installed" in result.stderr.lower():
|
|
186
|
+
return {
|
|
187
|
+
"method": "uv_tool_existing",
|
|
188
|
+
"installed": False,
|
|
189
|
+
"message": "keboola-mcp-server already installed via uv tool",
|
|
190
|
+
}
|
|
191
|
+
logger.warning("uv tool install failed: %s", result.stderr.strip())
|
|
192
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
193
|
+
logger.warning("uv tool install error: %s", exc)
|
|
194
|
+
|
|
195
|
+
# Fall back to uvx availability check
|
|
196
|
+
if shutil.which("uvx"):
|
|
197
|
+
return {
|
|
198
|
+
"method": "uvx_fallback",
|
|
199
|
+
"installed": False,
|
|
200
|
+
"message": (
|
|
201
|
+
"Using uvx (slower). Run "
|
|
202
|
+
f"'uv tool install {MCP_UV_PRERELEASE_FLAG} keboola-mcp-server' for faster startup"
|
|
203
|
+
),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"method": "not_found",
|
|
208
|
+
"installed": False,
|
|
209
|
+
"message": "keboola-mcp-server not found. Install with: pip install keboola-mcp-server",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_server_params(
|
|
214
|
+
project: ProjectConfig,
|
|
215
|
+
branch_id: str | None = None,
|
|
216
|
+
) -> StdioServerParameters:
|
|
217
|
+
"""Build StdioServerParameters for a project's MCP server.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
project: Project config with stack_url and token.
|
|
221
|
+
branch_id: Optional development branch ID to scope the MCP session.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
StdioServerParameters configured for the project.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ConfigError: If no MCP server command is available.
|
|
228
|
+
"""
|
|
229
|
+
command_parts = detect_mcp_server_command()
|
|
230
|
+
if command_parts is None:
|
|
231
|
+
raise ConfigError(
|
|
232
|
+
"Cannot find keboola-mcp-server. "
|
|
233
|
+
"Install it with: pip install keboola-mcp-server (or: uvx keboola_mcp_server)"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
env: dict[str, str] = {
|
|
237
|
+
"KBC_STORAGE_TOKEN": project.token,
|
|
238
|
+
"KBC_STORAGE_API_URL": project.stack_url,
|
|
239
|
+
}
|
|
240
|
+
if branch_id is not None:
|
|
241
|
+
env["KBC_BRANCH_ID"] = branch_id
|
|
242
|
+
|
|
243
|
+
return StdioServerParameters(
|
|
244
|
+
command=command_parts[0],
|
|
245
|
+
args=[*command_parts[1:], "--transport", "stdio"],
|
|
246
|
+
env=env,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _connect_and_list_tools(
|
|
251
|
+
project: ProjectConfig,
|
|
252
|
+
branch_id: str | None = None,
|
|
253
|
+
) -> list[dict[str, Any]]:
|
|
254
|
+
"""Connect to MCP server for a project and list available tools.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
project: Project config.
|
|
258
|
+
branch_id: Optional development branch ID.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of tool dicts with name, description, inputSchema.
|
|
262
|
+
"""
|
|
263
|
+
params = _build_server_params(project, branch_id=branch_id)
|
|
264
|
+
exit_stack = AsyncExitStack()
|
|
265
|
+
|
|
266
|
+
logger.info("Starting MCP server for project %s", project.project_name or "unknown")
|
|
267
|
+
try:
|
|
268
|
+
read_stream, write_stream = await asyncio.wait_for(
|
|
269
|
+
exit_stack.enter_async_context(stdio_client(params, errlog=subprocess.DEVNULL)), # ty: ignore[invalid-argument-type] # mcp types errlog as TextIO but accepts int (DEVNULL) at runtime
|
|
270
|
+
timeout=_get_init_timeout(),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
|
|
274
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
275
|
+
|
|
276
|
+
response = await asyncio.wait_for(session.list_tools(), timeout=_get_tool_timeout())
|
|
277
|
+
|
|
278
|
+
tools = []
|
|
279
|
+
for tool in response.tools:
|
|
280
|
+
tools.append(
|
|
281
|
+
{
|
|
282
|
+
"name": tool.name,
|
|
283
|
+
"description": tool.description or "",
|
|
284
|
+
"inputSchema": tool.inputSchema if tool.inputSchema else {},
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
logger.info("MCP server listed %d tools", len(tools))
|
|
288
|
+
return tools
|
|
289
|
+
finally:
|
|
290
|
+
logger.info("Closing MCP server session")
|
|
291
|
+
await exit_stack.aclose()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_content(result: Any) -> list[Any]:
|
|
295
|
+
"""Parse MCP tool result content items into Python objects."""
|
|
296
|
+
content_items = []
|
|
297
|
+
for item in result.content:
|
|
298
|
+
if hasattr(item, "text"):
|
|
299
|
+
try:
|
|
300
|
+
content_items.append(json.loads(item.text))
|
|
301
|
+
except (json.JSONDecodeError, TypeError):
|
|
302
|
+
content_items.append(item.text)
|
|
303
|
+
else:
|
|
304
|
+
content_items.append(str(item))
|
|
305
|
+
return content_items
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _open_session(
|
|
309
|
+
project: ProjectConfig,
|
|
310
|
+
exit_stack: AsyncExitStack,
|
|
311
|
+
branch_id: str | None = None,
|
|
312
|
+
) -> "ClientSession":
|
|
313
|
+
"""Open an MCP session for a project, managed by the given exit stack."""
|
|
314
|
+
logger.info("Opening MCP session for project %s", project.project_name or "unknown")
|
|
315
|
+
params = _build_server_params(project, branch_id=branch_id)
|
|
316
|
+
|
|
317
|
+
read_stream, write_stream = await asyncio.wait_for(
|
|
318
|
+
exit_stack.enter_async_context(stdio_client(params, errlog=subprocess.DEVNULL)), # ty: ignore[invalid-argument-type] # mcp types errlog as TextIO but accepts int (DEVNULL) at runtime
|
|
319
|
+
timeout=_get_init_timeout(),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
|
|
323
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
324
|
+
return session
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def _connect_and_call_tool(
|
|
328
|
+
project: ProjectConfig,
|
|
329
|
+
tool_name: str,
|
|
330
|
+
tool_input: dict[str, Any],
|
|
331
|
+
branch_id: str | None = None,
|
|
332
|
+
) -> dict[str, Any]:
|
|
333
|
+
"""Connect to MCP server for a project and call a specific tool.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
project: Project config.
|
|
337
|
+
tool_name: Name of the tool to call.
|
|
338
|
+
tool_input: Input arguments for the tool.
|
|
339
|
+
branch_id: Optional development branch ID.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dict with tool result content and error status.
|
|
343
|
+
"""
|
|
344
|
+
exit_stack = AsyncExitStack()
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
session = await _open_session(project, exit_stack, branch_id=branch_id)
|
|
348
|
+
|
|
349
|
+
result = await asyncio.wait_for(
|
|
350
|
+
session.call_tool(tool_name, tool_input),
|
|
351
|
+
timeout=_get_tool_timeout(),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
"content": _parse_content(result),
|
|
356
|
+
"isError": bool(result.isError),
|
|
357
|
+
}
|
|
358
|
+
finally:
|
|
359
|
+
await exit_stack.aclose()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def _connect_validate_and_call(
|
|
363
|
+
project: ProjectConfig,
|
|
364
|
+
tool_name: str,
|
|
365
|
+
tool_input: dict[str, Any],
|
|
366
|
+
branch_id: str | None = None,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Open ONE MCP session: validate tool name + schema, then call the tool.
|
|
369
|
+
|
|
370
|
+
Eliminates the need for a separate validate_tool_input() + call_tool()
|
|
371
|
+
sequence, saving one full subprocess spawn.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
project: Project config.
|
|
375
|
+
tool_name: Name of the MCP tool to call.
|
|
376
|
+
tool_input: Input arguments for the tool.
|
|
377
|
+
branch_id: Optional development branch ID.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Dict with "content", "isError", and "tool_schema" keys.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
ConfigError: If tool_name is not found in the available tool list.
|
|
384
|
+
ConfigError: If required parameters are missing (not auto-expandable).
|
|
385
|
+
"""
|
|
386
|
+
exit_stack = AsyncExitStack()
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
session = await _open_session(project, exit_stack, branch_id=branch_id)
|
|
390
|
+
|
|
391
|
+
# Step 1: list_tools to validate tool name and get schema
|
|
392
|
+
response = await asyncio.wait_for(session.list_tools(), timeout=_get_tool_timeout())
|
|
393
|
+
|
|
394
|
+
known_tools = {t.name for t in response.tools}
|
|
395
|
+
if tool_name not in known_tools:
|
|
396
|
+
raise ConfigError(
|
|
397
|
+
f"Unknown MCP tool '{tool_name}'. Use 'kbagent tool list' to see available tools."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Step 2: validate required params
|
|
401
|
+
schema: dict[str, Any] = {}
|
|
402
|
+
for tool in response.tools:
|
|
403
|
+
if tool.name == tool_name:
|
|
404
|
+
schema = tool.inputSchema if tool.inputSchema else {}
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
required = schema.get("required", [])
|
|
408
|
+
missing = [param for param in required if param not in tool_input]
|
|
409
|
+
|
|
410
|
+
# Exclude auto-expandable params from missing list
|
|
411
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
412
|
+
if expand_config:
|
|
413
|
+
auto_param = expand_config["param"]
|
|
414
|
+
missing = [p for p in missing if p != auto_param]
|
|
415
|
+
|
|
416
|
+
if missing:
|
|
417
|
+
import json as _json
|
|
418
|
+
|
|
419
|
+
params_str = ", ".join(missing)
|
|
420
|
+
example_json = _json.dumps({p: "..." for p in missing})
|
|
421
|
+
raise ConfigError(
|
|
422
|
+
f"Missing required parameter(s) for '{tool_name}': {params_str}. "
|
|
423
|
+
f"Use: kbagent tool call {tool_name} --input '{example_json}'"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Step 3: call the tool in the same session
|
|
427
|
+
result = await asyncio.wait_for(
|
|
428
|
+
session.call_tool(tool_name, tool_input),
|
|
429
|
+
timeout=_get_tool_timeout(),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"content": _parse_content(result),
|
|
434
|
+
"isError": bool(result.isError),
|
|
435
|
+
}
|
|
436
|
+
finally:
|
|
437
|
+
await exit_stack.aclose()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _get_transport_mode() -> str:
|
|
441
|
+
"""Get configured MCP transport mode ('http' or 'stdio')."""
|
|
442
|
+
return os.environ.get(ENV_MCP_TRANSPORT, DEFAULT_MCP_TRANSPORT)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _build_http_headers(
|
|
446
|
+
project: ProjectConfig,
|
|
447
|
+
branch_id: str | None = None,
|
|
448
|
+
) -> dict[str, str]:
|
|
449
|
+
"""Build HTTP headers for per-request project credentials."""
|
|
450
|
+
headers = {
|
|
451
|
+
"X-Storage-Token": project.token,
|
|
452
|
+
"X-Storage-API-URL": project.stack_url,
|
|
453
|
+
}
|
|
454
|
+
if branch_id:
|
|
455
|
+
headers["X-Branch-ID"] = branch_id
|
|
456
|
+
conversation_id = os.environ.get(ENV_CONVERSATION_ID, "")
|
|
457
|
+
if conversation_id:
|
|
458
|
+
headers["X-Conversation-ID"] = conversation_id
|
|
459
|
+
return headers
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def _http_list_tools(
|
|
463
|
+
base_url: str,
|
|
464
|
+
project: ProjectConfig,
|
|
465
|
+
branch_id: str | None = None,
|
|
466
|
+
) -> list[dict[str, Any]]:
|
|
467
|
+
"""List tools via HTTP transport (persistent server).
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
base_url: Base URL of the persistent MCP server.
|
|
471
|
+
project: Project config for authentication headers.
|
|
472
|
+
branch_id: Optional development branch ID.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
List of tool dicts with name, description, inputSchema.
|
|
476
|
+
"""
|
|
477
|
+
headers = _build_http_headers(project, branch_id)
|
|
478
|
+
url = f"{base_url}/mcp"
|
|
479
|
+
|
|
480
|
+
async with streamablehttp_client(url=url, headers=headers) as (
|
|
481
|
+
read_stream,
|
|
482
|
+
write_stream,
|
|
483
|
+
_,
|
|
484
|
+
):
|
|
485
|
+
session = ClientSession(read_stream, write_stream)
|
|
486
|
+
async with session:
|
|
487
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
488
|
+
|
|
489
|
+
response = await asyncio.wait_for(session.list_tools(), timeout=_get_tool_timeout())
|
|
490
|
+
|
|
491
|
+
tools = []
|
|
492
|
+
for tool in response.tools:
|
|
493
|
+
tools.append(
|
|
494
|
+
{
|
|
495
|
+
"name": tool.name,
|
|
496
|
+
"description": tool.description or "",
|
|
497
|
+
"inputSchema": tool.inputSchema if tool.inputSchema else {},
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return tools
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
async def _http_call_tool(
|
|
504
|
+
base_url: str,
|
|
505
|
+
project: ProjectConfig,
|
|
506
|
+
tool_name: str,
|
|
507
|
+
tool_input: dict[str, Any],
|
|
508
|
+
branch_id: str | None = None,
|
|
509
|
+
) -> dict[str, Any]:
|
|
510
|
+
"""Call a tool via HTTP transport (persistent server).
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
base_url: Base URL of the persistent MCP server.
|
|
514
|
+
project: Project config for authentication headers.
|
|
515
|
+
tool_name: Name of the tool to call.
|
|
516
|
+
tool_input: Input arguments for the tool.
|
|
517
|
+
branch_id: Optional development branch ID.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Dict with tool result content and error status.
|
|
521
|
+
"""
|
|
522
|
+
headers = _build_http_headers(project, branch_id)
|
|
523
|
+
url = f"{base_url}/mcp"
|
|
524
|
+
|
|
525
|
+
async with streamablehttp_client(url=url, headers=headers) as (
|
|
526
|
+
read_stream,
|
|
527
|
+
write_stream,
|
|
528
|
+
_,
|
|
529
|
+
):
|
|
530
|
+
session = ClientSession(read_stream, write_stream)
|
|
531
|
+
async with session:
|
|
532
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
533
|
+
|
|
534
|
+
result = await asyncio.wait_for(
|
|
535
|
+
session.call_tool(tool_name, tool_input),
|
|
536
|
+
timeout=_get_tool_timeout(),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
"content": _parse_content(result),
|
|
541
|
+
"isError": bool(result.isError),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
async def _http_validate_and_call(
|
|
546
|
+
base_url: str,
|
|
547
|
+
project: ProjectConfig,
|
|
548
|
+
tool_name: str,
|
|
549
|
+
tool_input: dict[str, Any],
|
|
550
|
+
branch_id: str | None = None,
|
|
551
|
+
) -> dict[str, Any]:
|
|
552
|
+
"""Validate and call a tool in one HTTP session (persistent server).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
base_url: Base URL of the persistent MCP server.
|
|
556
|
+
project: Project config for authentication headers.
|
|
557
|
+
tool_name: Name of the MCP tool to call.
|
|
558
|
+
tool_input: Input arguments for the tool.
|
|
559
|
+
branch_id: Optional development branch ID.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Dict with "content" and "isError" keys.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ConfigError: If tool_name not found or required params missing.
|
|
566
|
+
"""
|
|
567
|
+
headers = _build_http_headers(project, branch_id)
|
|
568
|
+
url = f"{base_url}/mcp"
|
|
569
|
+
|
|
570
|
+
async with streamablehttp_client(url=url, headers=headers) as (
|
|
571
|
+
read_stream,
|
|
572
|
+
write_stream,
|
|
573
|
+
_,
|
|
574
|
+
):
|
|
575
|
+
session = ClientSession(read_stream, write_stream)
|
|
576
|
+
async with session:
|
|
577
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
578
|
+
|
|
579
|
+
# Step 1: list_tools for validation
|
|
580
|
+
response = await asyncio.wait_for(session.list_tools(), timeout=_get_tool_timeout())
|
|
581
|
+
|
|
582
|
+
known_tools = {t.name for t in response.tools}
|
|
583
|
+
if tool_name not in known_tools:
|
|
584
|
+
raise ConfigError(
|
|
585
|
+
f"Unknown MCP tool '{tool_name}'. "
|
|
586
|
+
f"Use 'kbagent tool list' to see available tools."
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# Step 2: validate required params
|
|
590
|
+
schema: dict[str, Any] = {}
|
|
591
|
+
for tool in response.tools:
|
|
592
|
+
if tool.name == tool_name:
|
|
593
|
+
schema = tool.inputSchema if tool.inputSchema else {}
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
required = schema.get("required", [])
|
|
597
|
+
missing = [param for param in required if param not in tool_input]
|
|
598
|
+
|
|
599
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
600
|
+
if expand_config:
|
|
601
|
+
auto_param = expand_config["param"]
|
|
602
|
+
missing = [p for p in missing if p != auto_param]
|
|
603
|
+
|
|
604
|
+
if missing:
|
|
605
|
+
import json as _json
|
|
606
|
+
|
|
607
|
+
params_str = ", ".join(missing)
|
|
608
|
+
example_json = _json.dumps({p: "..." for p in missing})
|
|
609
|
+
raise ConfigError(
|
|
610
|
+
f"Missing required parameter(s) for '{tool_name}': {params_str}. "
|
|
611
|
+
f"Use: kbagent tool call {tool_name} --input '{example_json}'"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
# Step 3: call the tool
|
|
615
|
+
result = await asyncio.wait_for(
|
|
616
|
+
session.call_tool(tool_name, tool_input),
|
|
617
|
+
timeout=_get_tool_timeout(),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
"content": _parse_content(result),
|
|
622
|
+
"isError": bool(result.isError),
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _http_auto_expand(
|
|
627
|
+
base_url: str,
|
|
628
|
+
project: ProjectConfig,
|
|
629
|
+
tool_name: str,
|
|
630
|
+
tool_input: dict[str, Any],
|
|
631
|
+
expand_config: dict[str, str],
|
|
632
|
+
branch_id: str | None = None,
|
|
633
|
+
) -> dict[str, Any]:
|
|
634
|
+
"""Auto-expand a tool call via HTTP transport (persistent server).
|
|
635
|
+
|
|
636
|
+
Same logic as _connect_and_auto_expand but over HTTP.
|
|
637
|
+
"""
|
|
638
|
+
resolve_tool = expand_config["resolve_tool"]
|
|
639
|
+
resolve_key = expand_config["resolve_key"]
|
|
640
|
+
param_name = expand_config["param"]
|
|
641
|
+
|
|
642
|
+
headers = _build_http_headers(project, branch_id)
|
|
643
|
+
url = f"{base_url}/mcp"
|
|
644
|
+
|
|
645
|
+
async with streamablehttp_client(url=url, headers=headers) as (
|
|
646
|
+
read_stream,
|
|
647
|
+
write_stream,
|
|
648
|
+
_,
|
|
649
|
+
):
|
|
650
|
+
session = ClientSession(read_stream, write_stream)
|
|
651
|
+
async with session:
|
|
652
|
+
await asyncio.wait_for(session.initialize(), timeout=_get_init_timeout())
|
|
653
|
+
|
|
654
|
+
# Step 1: Call resolve tool
|
|
655
|
+
resolve_result = await asyncio.wait_for(
|
|
656
|
+
session.call_tool(resolve_tool, {}),
|
|
657
|
+
timeout=_get_tool_timeout(),
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if resolve_result.isError:
|
|
661
|
+
return {
|
|
662
|
+
"content": _parse_content(resolve_result),
|
|
663
|
+
"isError": True,
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
resolve_items = _parse_content(resolve_result)
|
|
667
|
+
item_ids = _extract_ids(resolve_items, resolve_key)
|
|
668
|
+
|
|
669
|
+
if not item_ids:
|
|
670
|
+
return {"content": [], "isError": False}
|
|
671
|
+
|
|
672
|
+
# Step 2: Call target tool with all resolved IDs as array
|
|
673
|
+
call_input = {**tool_input, param_name: list(item_ids)}
|
|
674
|
+
result = await asyncio.wait_for(
|
|
675
|
+
session.call_tool(tool_name, call_input),
|
|
676
|
+
timeout=_get_tool_timeout(),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
"content": _parse_content(result),
|
|
681
|
+
"isError": bool(result.isError),
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
async def _connect_and_auto_expand(
|
|
686
|
+
project: ProjectConfig,
|
|
687
|
+
tool_name: str,
|
|
688
|
+
tool_input: dict[str, Any],
|
|
689
|
+
expand_config: dict[str, str],
|
|
690
|
+
branch_id: str | None = None,
|
|
691
|
+
) -> dict[str, Any]:
|
|
692
|
+
"""Connect to MCP server and auto-expand a tool call.
|
|
693
|
+
|
|
694
|
+
First calls the resolve_tool to get a list of items, then calls
|
|
695
|
+
the target tool for each item, reusing one MCP session.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
project: Project config.
|
|
699
|
+
tool_name: Target tool name (e.g. "get_tables").
|
|
700
|
+
tool_input: Base input for the target tool (without the auto-expanded param).
|
|
701
|
+
expand_config: Dict with "param", "resolve_tool", "resolve_key".
|
|
702
|
+
branch_id: Optional development branch ID.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
Dict with aggregated content and error status.
|
|
706
|
+
"""
|
|
707
|
+
resolve_tool = expand_config["resolve_tool"]
|
|
708
|
+
resolve_key = expand_config["resolve_key"]
|
|
709
|
+
param_name = expand_config["param"]
|
|
710
|
+
|
|
711
|
+
exit_stack = AsyncExitStack()
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
session = await _open_session(project, exit_stack, branch_id=branch_id)
|
|
715
|
+
|
|
716
|
+
# Step 1: Call resolve tool (e.g. get_buckets)
|
|
717
|
+
resolve_result = await asyncio.wait_for(
|
|
718
|
+
session.call_tool(resolve_tool, {}),
|
|
719
|
+
timeout=_get_tool_timeout(),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
if resolve_result.isError:
|
|
723
|
+
return {
|
|
724
|
+
"content": _parse_content(resolve_result),
|
|
725
|
+
"isError": True,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Extract IDs from resolve result
|
|
729
|
+
resolve_items = _parse_content(resolve_result)
|
|
730
|
+
item_ids = _extract_ids(resolve_items, resolve_key)
|
|
731
|
+
|
|
732
|
+
if not item_ids:
|
|
733
|
+
return {"content": [], "isError": False}
|
|
734
|
+
|
|
735
|
+
# Step 2: Call target tool with all resolved IDs as array
|
|
736
|
+
call_input = {**tool_input, param_name: list(item_ids)}
|
|
737
|
+
result = await asyncio.wait_for(
|
|
738
|
+
session.call_tool(tool_name, call_input),
|
|
739
|
+
timeout=_get_tool_timeout(),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
"content": _parse_content(result),
|
|
744
|
+
"isError": bool(result.isError),
|
|
745
|
+
}
|
|
746
|
+
finally:
|
|
747
|
+
await exit_stack.aclose()
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _extract_ids_from_toon(text: str, key: str) -> list[str]:
|
|
751
|
+
"""Extract ID values from a TOON-formatted string.
|
|
752
|
+
|
|
753
|
+
Parses TOON array headers like ``items[N]{field1,field2,...}:`` to find
|
|
754
|
+
the column position of *key*, then reads each indented data line using
|
|
755
|
+
CSV parsing (handles quoted values with commas).
|
|
756
|
+
"""
|
|
757
|
+
import csv
|
|
758
|
+
import io
|
|
759
|
+
import re
|
|
760
|
+
|
|
761
|
+
ids: list[str] = []
|
|
762
|
+
header_re = re.compile(r"\w+\[\d+\]\{([^}]+)\}:")
|
|
763
|
+
field_index: int | None = None
|
|
764
|
+
|
|
765
|
+
for line in text.split("\n"):
|
|
766
|
+
m = header_re.search(line)
|
|
767
|
+
if m:
|
|
768
|
+
fields = [f.strip() for f in m.group(1).split(",")]
|
|
769
|
+
try:
|
|
770
|
+
field_index = fields.index(key)
|
|
771
|
+
except ValueError:
|
|
772
|
+
field_index = None
|
|
773
|
+
continue
|
|
774
|
+
|
|
775
|
+
if field_index is not None and line.startswith(" "):
|
|
776
|
+
stripped = line.strip()
|
|
777
|
+
if stripped:
|
|
778
|
+
try:
|
|
779
|
+
values = next(csv.reader(io.StringIO(stripped)))
|
|
780
|
+
if len(values) > field_index:
|
|
781
|
+
ids.append(values[field_index])
|
|
782
|
+
except Exception:
|
|
783
|
+
pass
|
|
784
|
+
|
|
785
|
+
return ids
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _extract_ids(content_items: list[Any], key: str) -> list[str]:
|
|
789
|
+
"""Extract unique ID values from parsed MCP tool content.
|
|
790
|
+
|
|
791
|
+
Handles JSON dicts/lists **and** TOON-formatted text strings.
|
|
792
|
+
Deduplicates while preserving insertion order.
|
|
793
|
+
"""
|
|
794
|
+
ids: list[str] = []
|
|
795
|
+
for item in content_items:
|
|
796
|
+
if isinstance(item, str):
|
|
797
|
+
ids.extend(_extract_ids_from_toon(item, key))
|
|
798
|
+
elif isinstance(item, list):
|
|
799
|
+
for sub in item:
|
|
800
|
+
if isinstance(sub, dict) and key in sub:
|
|
801
|
+
ids.append(str(sub[key]))
|
|
802
|
+
elif isinstance(item, dict):
|
|
803
|
+
if key in item:
|
|
804
|
+
ids.append(str(item[key]))
|
|
805
|
+
# Handle nested list in a single response dict
|
|
806
|
+
for value in item.values():
|
|
807
|
+
if isinstance(value, list):
|
|
808
|
+
for sub in value:
|
|
809
|
+
if isinstance(sub, dict) and key in sub:
|
|
810
|
+
ids.append(str(sub[key]))
|
|
811
|
+
return list(dict.fromkeys(ids))
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
class McpService(BaseService):
|
|
815
|
+
"""Business logic for MCP tool operations across projects.
|
|
816
|
+
|
|
817
|
+
Supports two transport modes:
|
|
818
|
+
- HTTP (default): Uses a persistent server via McpServerManager.
|
|
819
|
+
One server serves all projects with per-request credential headers.
|
|
820
|
+
- stdio: Spawns a subprocess per MCP session. Fallback mode.
|
|
821
|
+
|
|
822
|
+
Read tools execute across all projects in parallel.
|
|
823
|
+
Write tools target a single project.
|
|
824
|
+
|
|
825
|
+
Uses the same DI pattern as JobService/ConfigService.
|
|
826
|
+
"""
|
|
827
|
+
|
|
828
|
+
def _get_server_url(self) -> str | None:
|
|
829
|
+
"""Get the persistent server URL if HTTP transport is configured.
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
Base URL string if HTTP transport is active and server is running,
|
|
833
|
+
None if stdio mode or server cannot be started.
|
|
834
|
+
"""
|
|
835
|
+
if _get_transport_mode() != "http":
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
try:
|
|
839
|
+
from .mcp_transport import get_server_manager
|
|
840
|
+
|
|
841
|
+
manager = get_server_manager()
|
|
842
|
+
return manager.ensure_running()
|
|
843
|
+
except Exception as exc:
|
|
844
|
+
logger.warning("Failed to start persistent MCP server, falling back to stdio: %s", exc)
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
def resolve_project(self, alias: str | None = None) -> tuple[str, ProjectConfig]:
|
|
848
|
+
"""Resolve a single project alias (or the default project).
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
alias: Project alias. If None, uses default_project.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
Tuple of (alias, ProjectConfig).
|
|
855
|
+
|
|
856
|
+
Raises:
|
|
857
|
+
ConfigError: If alias not found or no default project set.
|
|
858
|
+
"""
|
|
859
|
+
config = self._config_store.load()
|
|
860
|
+
|
|
861
|
+
if alias is None:
|
|
862
|
+
alias = config.default_project
|
|
863
|
+
if not alias:
|
|
864
|
+
raise ConfigError(
|
|
865
|
+
"No default project set. Use 'kbagent project add' or specify --project."
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
if alias not in config.projects:
|
|
869
|
+
raise ConfigError(f"Project '{alias}' not found.")
|
|
870
|
+
|
|
871
|
+
return alias, config.projects[alias]
|
|
872
|
+
|
|
873
|
+
def list_tools(
|
|
874
|
+
self,
|
|
875
|
+
aliases: list[str] | None = None,
|
|
876
|
+
branch_id: str | None = None,
|
|
877
|
+
) -> dict[str, Any]:
|
|
878
|
+
"""List available MCP tools from the first reachable project.
|
|
879
|
+
|
|
880
|
+
Tools are the same across projects (same MCP server), so we only
|
|
881
|
+
need to query one project. Each tool is annotated with multi_project
|
|
882
|
+
flag based on read/write classification.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
aliases: Project aliases. Uses first available if None.
|
|
886
|
+
branch_id: Optional development branch ID.
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
Dict with "tools" list and "errors" list.
|
|
890
|
+
|
|
891
|
+
Raises:
|
|
892
|
+
ConfigError: If no projects are configured.
|
|
893
|
+
"""
|
|
894
|
+
projects = self.resolve_projects(aliases)
|
|
895
|
+
|
|
896
|
+
if not projects:
|
|
897
|
+
raise ConfigError("No projects configured. Use 'kbagent project add' first.")
|
|
898
|
+
|
|
899
|
+
# Try HTTP transport first, fall back to stdio
|
|
900
|
+
server_url = self._get_server_url()
|
|
901
|
+
|
|
902
|
+
errors: list[dict[str, str]] = []
|
|
903
|
+
for alias, project in projects.items():
|
|
904
|
+
try:
|
|
905
|
+
if server_url:
|
|
906
|
+
tools = asyncio.run(_http_list_tools(server_url, project, branch_id=branch_id))
|
|
907
|
+
else:
|
|
908
|
+
tools = asyncio.run(_connect_and_list_tools(project, branch_id=branch_id))
|
|
909
|
+
# Annotate tools with multi_project flag
|
|
910
|
+
annotated_tools = []
|
|
911
|
+
for tool in tools:
|
|
912
|
+
tool["multi_project"] = not _is_write_tool(tool["name"])
|
|
913
|
+
annotated_tools.append(tool)
|
|
914
|
+
|
|
915
|
+
return {"tools": annotated_tools, "errors": errors}
|
|
916
|
+
except Exception as exc:
|
|
917
|
+
errors.append(
|
|
918
|
+
{
|
|
919
|
+
"project_alias": alias,
|
|
920
|
+
"error_code": "MCP_ERROR",
|
|
921
|
+
"message": f"Failed to list tools: {exc}",
|
|
922
|
+
}
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
return {"tools": [], "errors": errors}
|
|
926
|
+
|
|
927
|
+
def get_tool_schema(
|
|
928
|
+
self,
|
|
929
|
+
tool_name: str,
|
|
930
|
+
aliases: list[str] | None = None,
|
|
931
|
+
branch_id: str | None = None,
|
|
932
|
+
) -> dict[str, Any] | None:
|
|
933
|
+
"""Get the input schema for a specific tool.
|
|
934
|
+
|
|
935
|
+
Fetches tool list from the first available project and finds the
|
|
936
|
+
matching tool's inputSchema.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
tool_name: Name of the tool.
|
|
940
|
+
aliases: Project aliases to try.
|
|
941
|
+
branch_id: Optional development branch ID.
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
The tool's inputSchema dict, or None if tool not found.
|
|
945
|
+
"""
|
|
946
|
+
result = self.list_tools(aliases=aliases, branch_id=branch_id)
|
|
947
|
+
for tool in result.get("tools", []):
|
|
948
|
+
if tool["name"] == tool_name:
|
|
949
|
+
return tool.get("inputSchema", {})
|
|
950
|
+
return None
|
|
951
|
+
|
|
952
|
+
def validate_tool_input(
|
|
953
|
+
self,
|
|
954
|
+
tool_name: str,
|
|
955
|
+
tool_input: dict[str, Any],
|
|
956
|
+
aliases: list[str] | None = None,
|
|
957
|
+
branch_id: str | None = None,
|
|
958
|
+
) -> tuple[list[str], set[str]]:
|
|
959
|
+
"""Validate tool input against the tool's schema.
|
|
960
|
+
|
|
961
|
+
Checks that all required parameters are provided.
|
|
962
|
+
Parameters that can be auto-expanded (see AUTO_EXPAND_TOOLS) are
|
|
963
|
+
excluded from the missing list.
|
|
964
|
+
|
|
965
|
+
Also returns the set of known tool names so callers can pass it
|
|
966
|
+
to call_tool() and avoid a redundant list_tools() MCP session.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
tool_name: Name of the tool.
|
|
970
|
+
tool_input: Input arguments to validate.
|
|
971
|
+
aliases: Project aliases to try for schema lookup.
|
|
972
|
+
branch_id: Optional development branch ID.
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
Tuple of (missing_params, known_tool_names).
|
|
976
|
+
"""
|
|
977
|
+
result = self.list_tools(aliases=aliases, branch_id=branch_id)
|
|
978
|
+
known_tools = {t["name"] for t in result.get("tools", [])}
|
|
979
|
+
|
|
980
|
+
# Find the schema for this tool
|
|
981
|
+
schema: dict[str, Any] | None = None
|
|
982
|
+
for tool in result.get("tools", []):
|
|
983
|
+
if tool["name"] == tool_name:
|
|
984
|
+
schema = tool.get("inputSchema", {})
|
|
985
|
+
break
|
|
986
|
+
|
|
987
|
+
if schema is None:
|
|
988
|
+
return [], known_tools # Tool not found; call_tool will raise ConfigError
|
|
989
|
+
|
|
990
|
+
required = schema.get("required", [])
|
|
991
|
+
missing = [param for param in required if param not in tool_input]
|
|
992
|
+
|
|
993
|
+
# Exclude auto-expandable params from missing list
|
|
994
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
995
|
+
if expand_config:
|
|
996
|
+
auto_param = expand_config["param"]
|
|
997
|
+
missing = [p for p in missing if p != auto_param]
|
|
998
|
+
|
|
999
|
+
return missing, known_tools
|
|
1000
|
+
|
|
1001
|
+
def _check_tool_permission(self, tool_name: str) -> None:
|
|
1002
|
+
"""Check if the MCP tool is allowed by the active permission policy.
|
|
1003
|
+
|
|
1004
|
+
Raises:
|
|
1005
|
+
PermissionDeniedError: If the tool is blocked by the policy.
|
|
1006
|
+
"""
|
|
1007
|
+
config = self._config_store.load()
|
|
1008
|
+
if config.permissions is None:
|
|
1009
|
+
return
|
|
1010
|
+
engine = PermissionEngine(config.permissions)
|
|
1011
|
+
engine.check_or_raise(f"tool:{tool_name}")
|
|
1012
|
+
|
|
1013
|
+
def call_tool(
|
|
1014
|
+
self,
|
|
1015
|
+
tool_name: str,
|
|
1016
|
+
tool_input: dict[str, Any] | None = None,
|
|
1017
|
+
alias: str | None = None,
|
|
1018
|
+
branch_id: str | None = None,
|
|
1019
|
+
_known_tools: set[str] | None = None,
|
|
1020
|
+
) -> dict[str, Any]:
|
|
1021
|
+
"""Call an MCP tool.
|
|
1022
|
+
|
|
1023
|
+
For read tools (multi_project=true): runs across ALL projects in parallel,
|
|
1024
|
+
aggregates results with project_alias annotation.
|
|
1025
|
+
|
|
1026
|
+
For write tools: runs on a single project (specified by alias or default).
|
|
1027
|
+
|
|
1028
|
+
When branch_id is provided, forces single-project mode regardless of
|
|
1029
|
+
read/write classification (branch ID is per-project).
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
tool_name: Name of the MCP tool to call.
|
|
1033
|
+
tool_input: Input arguments for the tool.
|
|
1034
|
+
alias: Project alias for write tools. Ignored for read tools
|
|
1035
|
+
unless specified to limit scope.
|
|
1036
|
+
branch_id: Optional development branch ID. Forces single-project mode.
|
|
1037
|
+
_known_tools: Optional set of known tool names from a prior
|
|
1038
|
+
validate_tool_input() call. When provided, skips the
|
|
1039
|
+
internal list_tools() call (saves one MCP subprocess).
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
Dict with "results" list and "errors" list.
|
|
1043
|
+
|
|
1044
|
+
Raises:
|
|
1045
|
+
ConfigError: If tool_name is not found in the available tool list.
|
|
1046
|
+
"""
|
|
1047
|
+
self._check_tool_permission(tool_name)
|
|
1048
|
+
|
|
1049
|
+
if tool_input is None:
|
|
1050
|
+
tool_input = {}
|
|
1051
|
+
|
|
1052
|
+
# Validate tool name exists in the MCP tool list
|
|
1053
|
+
if _known_tools is not None:
|
|
1054
|
+
known_tools = _known_tools
|
|
1055
|
+
else:
|
|
1056
|
+
tool_list_result = self.list_tools(
|
|
1057
|
+
aliases=[alias] if alias else None,
|
|
1058
|
+
branch_id=branch_id,
|
|
1059
|
+
)
|
|
1060
|
+
known_tools = {t["name"] for t in tool_list_result.get("tools", [])}
|
|
1061
|
+
if known_tools and tool_name not in known_tools:
|
|
1062
|
+
raise ConfigError(
|
|
1063
|
+
f"Unknown MCP tool '{tool_name}'. Use 'kbagent tool list' to see available tools."
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
is_write = _is_write_tool(tool_name)
|
|
1067
|
+
|
|
1068
|
+
# When branch_id is set, force single-project mode
|
|
1069
|
+
if branch_id is not None or is_write:
|
|
1070
|
+
return self._call_write_tool(tool_name, tool_input, alias, branch_id=branch_id)
|
|
1071
|
+
else:
|
|
1072
|
+
return self._call_read_tool(tool_name, tool_input, alias, branch_id=branch_id)
|
|
1073
|
+
|
|
1074
|
+
def validate_and_call_tool(
|
|
1075
|
+
self,
|
|
1076
|
+
tool_name: str,
|
|
1077
|
+
tool_input: dict[str, Any] | None = None,
|
|
1078
|
+
alias: str | None = None,
|
|
1079
|
+
branch_id: str | None = None,
|
|
1080
|
+
) -> dict[str, Any]:
|
|
1081
|
+
"""Validate and call an MCP tool in a single session (no double spawn).
|
|
1082
|
+
|
|
1083
|
+
Opens ONE MCP session: validates tool name + required params from
|
|
1084
|
+
list_tools(), then calls the tool. This eliminates the separate
|
|
1085
|
+
validate_tool_input() + call_tool() round-trip.
|
|
1086
|
+
|
|
1087
|
+
Prefers HTTP transport (persistent server) with fallback to stdio.
|
|
1088
|
+
|
|
1089
|
+
For write tools and branch-scoped calls: single project.
|
|
1090
|
+
For read tools: still runs across all projects in parallel, but each
|
|
1091
|
+
project's session validates + calls in one go.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
tool_name: Name of the MCP tool to call.
|
|
1095
|
+
tool_input: Input arguments for the tool.
|
|
1096
|
+
alias: Project alias for write tools / single-project mode.
|
|
1097
|
+
branch_id: Optional development branch ID. Forces single-project mode.
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
Dict with "results" list and "errors" list.
|
|
1101
|
+
|
|
1102
|
+
Raises:
|
|
1103
|
+
ConfigError: If tool_name not found or required params missing.
|
|
1104
|
+
"""
|
|
1105
|
+
self._check_tool_permission(tool_name)
|
|
1106
|
+
|
|
1107
|
+
if tool_input is None:
|
|
1108
|
+
tool_input = {}
|
|
1109
|
+
|
|
1110
|
+
server_url = self._get_server_url()
|
|
1111
|
+
is_write = _is_write_tool(tool_name)
|
|
1112
|
+
|
|
1113
|
+
# Single-project mode: write tools, branch-scoped, or explicit alias
|
|
1114
|
+
if branch_id is not None or is_write:
|
|
1115
|
+
resolved_alias, project = self.resolve_project(alias)
|
|
1116
|
+
try:
|
|
1117
|
+
# Check if auto-expand needed
|
|
1118
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
1119
|
+
if expand_config and expand_config["param"] not in tool_input:
|
|
1120
|
+
if server_url:
|
|
1121
|
+
result = asyncio.run(
|
|
1122
|
+
_http_auto_expand(
|
|
1123
|
+
server_url,
|
|
1124
|
+
project,
|
|
1125
|
+
tool_name,
|
|
1126
|
+
tool_input,
|
|
1127
|
+
expand_config,
|
|
1128
|
+
branch_id=branch_id,
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
else:
|
|
1132
|
+
result = asyncio.run(
|
|
1133
|
+
_connect_and_auto_expand(
|
|
1134
|
+
project,
|
|
1135
|
+
tool_name,
|
|
1136
|
+
tool_input,
|
|
1137
|
+
expand_config,
|
|
1138
|
+
branch_id=branch_id,
|
|
1139
|
+
)
|
|
1140
|
+
)
|
|
1141
|
+
else:
|
|
1142
|
+
if server_url:
|
|
1143
|
+
result = asyncio.run(
|
|
1144
|
+
_http_validate_and_call(
|
|
1145
|
+
server_url,
|
|
1146
|
+
project,
|
|
1147
|
+
tool_name,
|
|
1148
|
+
tool_input,
|
|
1149
|
+
branch_id=branch_id,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
else:
|
|
1153
|
+
result = asyncio.run(
|
|
1154
|
+
_connect_validate_and_call(
|
|
1155
|
+
project,
|
|
1156
|
+
tool_name,
|
|
1157
|
+
tool_input,
|
|
1158
|
+
branch_id=branch_id,
|
|
1159
|
+
)
|
|
1160
|
+
)
|
|
1161
|
+
result["project_alias"] = resolved_alias
|
|
1162
|
+
return {"results": [result], "errors": []}
|
|
1163
|
+
except ConfigError:
|
|
1164
|
+
raise
|
|
1165
|
+
except Exception as exc:
|
|
1166
|
+
return {
|
|
1167
|
+
"results": [],
|
|
1168
|
+
"errors": [
|
|
1169
|
+
{
|
|
1170
|
+
"project_alias": resolved_alias,
|
|
1171
|
+
"error_code": "MCP_ERROR",
|
|
1172
|
+
"message": str(exc),
|
|
1173
|
+
}
|
|
1174
|
+
],
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
# Multi-project read: parallel validate+call across all projects
|
|
1178
|
+
projects = self.resolve_projects([alias]) if alias else self.resolve_projects()
|
|
1179
|
+
if not projects:
|
|
1180
|
+
raise ConfigError("No projects configured. Use 'kbagent project add' first.")
|
|
1181
|
+
|
|
1182
|
+
# Check if auto-expand is needed
|
|
1183
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
1184
|
+
if expand_config and expand_config["param"] not in tool_input:
|
|
1185
|
+
return asyncio.run(
|
|
1186
|
+
self._gather_auto_expand_results(
|
|
1187
|
+
projects,
|
|
1188
|
+
tool_name,
|
|
1189
|
+
tool_input,
|
|
1190
|
+
expand_config,
|
|
1191
|
+
branch_id=branch_id,
|
|
1192
|
+
server_url=server_url,
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
return asyncio.run(
|
|
1197
|
+
self._gather_validate_and_call_results(
|
|
1198
|
+
projects,
|
|
1199
|
+
tool_name,
|
|
1200
|
+
tool_input,
|
|
1201
|
+
branch_id=branch_id,
|
|
1202
|
+
server_url=server_url,
|
|
1203
|
+
)
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
async def _gather_validate_and_call_results(
|
|
1207
|
+
self,
|
|
1208
|
+
projects: dict[str, ProjectConfig],
|
|
1209
|
+
tool_name: str,
|
|
1210
|
+
tool_input: dict[str, Any],
|
|
1211
|
+
branch_id: str | None = None,
|
|
1212
|
+
server_url: str | None = None,
|
|
1213
|
+
) -> dict[str, Any]:
|
|
1214
|
+
"""Run validate+call across multiple projects in parallel.
|
|
1215
|
+
|
|
1216
|
+
Each project opens one session that validates and calls the tool.
|
|
1217
|
+
Uses HTTP transport when server_url is available.
|
|
1218
|
+
"""
|
|
1219
|
+
max_sessions = _get_max_sessions()
|
|
1220
|
+
sem = asyncio.Semaphore(max_sessions) if max_sessions > 0 else None
|
|
1221
|
+
tasks = {}
|
|
1222
|
+
for a, project in projects.items():
|
|
1223
|
+
if server_url:
|
|
1224
|
+
coro = _http_validate_and_call(
|
|
1225
|
+
server_url,
|
|
1226
|
+
project,
|
|
1227
|
+
tool_name,
|
|
1228
|
+
tool_input,
|
|
1229
|
+
branch_id=branch_id,
|
|
1230
|
+
)
|
|
1231
|
+
else:
|
|
1232
|
+
coro = _connect_validate_and_call(
|
|
1233
|
+
project,
|
|
1234
|
+
tool_name,
|
|
1235
|
+
tool_input,
|
|
1236
|
+
branch_id=branch_id,
|
|
1237
|
+
)
|
|
1238
|
+
if sem is not None:
|
|
1239
|
+
coro = _semaphored(sem, coro)
|
|
1240
|
+
tasks[a] = asyncio.create_task(coro)
|
|
1241
|
+
return await self._gather_results(tasks)
|
|
1242
|
+
|
|
1243
|
+
def _call_write_tool(
|
|
1244
|
+
self,
|
|
1245
|
+
tool_name: str,
|
|
1246
|
+
tool_input: dict[str, Any],
|
|
1247
|
+
alias: str | None,
|
|
1248
|
+
branch_id: str | None = None,
|
|
1249
|
+
) -> dict[str, Any]:
|
|
1250
|
+
"""Execute a write tool on a single project."""
|
|
1251
|
+
resolved_alias, project = self.resolve_project(alias)
|
|
1252
|
+
server_url = self._get_server_url()
|
|
1253
|
+
|
|
1254
|
+
try:
|
|
1255
|
+
if server_url:
|
|
1256
|
+
result = asyncio.run(
|
|
1257
|
+
_http_call_tool(
|
|
1258
|
+
server_url,
|
|
1259
|
+
project,
|
|
1260
|
+
tool_name,
|
|
1261
|
+
tool_input,
|
|
1262
|
+
branch_id=branch_id,
|
|
1263
|
+
)
|
|
1264
|
+
)
|
|
1265
|
+
else:
|
|
1266
|
+
result = asyncio.run(
|
|
1267
|
+
_connect_and_call_tool(
|
|
1268
|
+
project,
|
|
1269
|
+
tool_name,
|
|
1270
|
+
tool_input,
|
|
1271
|
+
branch_id=branch_id,
|
|
1272
|
+
)
|
|
1273
|
+
)
|
|
1274
|
+
result["project_alias"] = resolved_alias
|
|
1275
|
+
return {"results": [result], "errors": []}
|
|
1276
|
+
except Exception as exc:
|
|
1277
|
+
return {
|
|
1278
|
+
"results": [],
|
|
1279
|
+
"errors": [
|
|
1280
|
+
{
|
|
1281
|
+
"project_alias": resolved_alias,
|
|
1282
|
+
"error_code": "MCP_ERROR",
|
|
1283
|
+
"message": str(exc),
|
|
1284
|
+
}
|
|
1285
|
+
],
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
def _call_read_tool(
|
|
1289
|
+
self,
|
|
1290
|
+
tool_name: str,
|
|
1291
|
+
tool_input: dict[str, Any],
|
|
1292
|
+
alias: str | None,
|
|
1293
|
+
branch_id: str | None = None,
|
|
1294
|
+
) -> dict[str, Any]:
|
|
1295
|
+
"""Execute a read tool across projects in parallel.
|
|
1296
|
+
|
|
1297
|
+
If the tool is in AUTO_EXPAND_TOOLS and the required param is missing,
|
|
1298
|
+
automatically resolves it by calling the resolve tool first.
|
|
1299
|
+
"""
|
|
1300
|
+
projects = self.resolve_projects([alias]) if alias else self.resolve_projects()
|
|
1301
|
+
|
|
1302
|
+
if not projects:
|
|
1303
|
+
raise ConfigError("No projects configured. Use 'kbagent project add' first.")
|
|
1304
|
+
|
|
1305
|
+
server_url = self._get_server_url()
|
|
1306
|
+
|
|
1307
|
+
# Check if auto-expand is needed
|
|
1308
|
+
expand_config = AUTO_EXPAND_TOOLS.get(tool_name)
|
|
1309
|
+
if expand_config and expand_config["param"] not in tool_input:
|
|
1310
|
+
return asyncio.run(
|
|
1311
|
+
self._gather_auto_expand_results(
|
|
1312
|
+
projects,
|
|
1313
|
+
tool_name,
|
|
1314
|
+
tool_input,
|
|
1315
|
+
expand_config,
|
|
1316
|
+
branch_id=branch_id,
|
|
1317
|
+
server_url=server_url,
|
|
1318
|
+
)
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
return asyncio.run(
|
|
1322
|
+
self._gather_read_results(
|
|
1323
|
+
projects,
|
|
1324
|
+
tool_name,
|
|
1325
|
+
tool_input,
|
|
1326
|
+
branch_id=branch_id,
|
|
1327
|
+
server_url=server_url,
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
@staticmethod
|
|
1332
|
+
async def _gather_results(
|
|
1333
|
+
tasks: dict[str, "asyncio.Task[dict[str, Any]]"],
|
|
1334
|
+
) -> dict[str, Any]:
|
|
1335
|
+
"""Gather results from async tasks using asyncio.gather.
|
|
1336
|
+
|
|
1337
|
+
Shared helper for both read and auto-expand gather operations.
|
|
1338
|
+
Uses asyncio.gather with return_exceptions=True for true concurrency.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
tasks: Dict mapping project alias to asyncio.Task.
|
|
1342
|
+
|
|
1343
|
+
Returns:
|
|
1344
|
+
Dict with "results" list and "errors" list.
|
|
1345
|
+
"""
|
|
1346
|
+
aliases = list(tasks.keys())
|
|
1347
|
+
outcomes = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
1348
|
+
|
|
1349
|
+
all_results: list[dict[str, Any]] = []
|
|
1350
|
+
errors: list[dict[str, str]] = []
|
|
1351
|
+
|
|
1352
|
+
for alias, outcome in zip(aliases, outcomes, strict=False):
|
|
1353
|
+
if isinstance(outcome, BaseException):
|
|
1354
|
+
errors.append(
|
|
1355
|
+
{
|
|
1356
|
+
"project_alias": alias,
|
|
1357
|
+
"error_code": "MCP_ERROR",
|
|
1358
|
+
"message": str(outcome),
|
|
1359
|
+
}
|
|
1360
|
+
)
|
|
1361
|
+
else:
|
|
1362
|
+
outcome["project_alias"] = alias
|
|
1363
|
+
all_results.append(outcome)
|
|
1364
|
+
|
|
1365
|
+
return {"results": all_results, "errors": errors}
|
|
1366
|
+
|
|
1367
|
+
async def _gather_auto_expand_results(
|
|
1368
|
+
self,
|
|
1369
|
+
projects: dict[str, ProjectConfig],
|
|
1370
|
+
tool_name: str,
|
|
1371
|
+
tool_input: dict[str, Any],
|
|
1372
|
+
expand_config: dict[str, str],
|
|
1373
|
+
branch_id: str | None = None,
|
|
1374
|
+
server_url: str | None = None,
|
|
1375
|
+
) -> dict[str, Any]:
|
|
1376
|
+
"""Run an auto-expanded tool across multiple projects in parallel.
|
|
1377
|
+
|
|
1378
|
+
For each project, opens one MCP session, resolves the missing param
|
|
1379
|
+
by calling the resolve tool, then calls the target tool per item.
|
|
1380
|
+
Uses HTTP transport when server_url is available.
|
|
1381
|
+
"""
|
|
1382
|
+
max_sessions = _get_max_sessions()
|
|
1383
|
+
sem = asyncio.Semaphore(max_sessions) if max_sessions > 0 else None
|
|
1384
|
+
tasks = {}
|
|
1385
|
+
for a, project in projects.items():
|
|
1386
|
+
if server_url:
|
|
1387
|
+
coro = _http_auto_expand(
|
|
1388
|
+
server_url,
|
|
1389
|
+
project,
|
|
1390
|
+
tool_name,
|
|
1391
|
+
tool_input,
|
|
1392
|
+
expand_config,
|
|
1393
|
+
branch_id=branch_id,
|
|
1394
|
+
)
|
|
1395
|
+
else:
|
|
1396
|
+
coro = _connect_and_auto_expand(
|
|
1397
|
+
project,
|
|
1398
|
+
tool_name,
|
|
1399
|
+
tool_input,
|
|
1400
|
+
expand_config,
|
|
1401
|
+
branch_id=branch_id,
|
|
1402
|
+
)
|
|
1403
|
+
if sem is not None:
|
|
1404
|
+
coro = _semaphored(sem, coro)
|
|
1405
|
+
tasks[a] = asyncio.create_task(coro)
|
|
1406
|
+
return await self._gather_results(tasks)
|
|
1407
|
+
|
|
1408
|
+
async def _gather_read_results(
|
|
1409
|
+
self,
|
|
1410
|
+
projects: dict[str, ProjectConfig],
|
|
1411
|
+
tool_name: str,
|
|
1412
|
+
tool_input: dict[str, Any],
|
|
1413
|
+
branch_id: str | None = None,
|
|
1414
|
+
server_url: str | None = None,
|
|
1415
|
+
) -> dict[str, Any]:
|
|
1416
|
+
"""Run a read tool across multiple projects in parallel using asyncio.gather.
|
|
1417
|
+
|
|
1418
|
+
Uses HTTP transport when server_url is available.
|
|
1419
|
+
"""
|
|
1420
|
+
max_sessions = _get_max_sessions()
|
|
1421
|
+
sem = asyncio.Semaphore(max_sessions) if max_sessions > 0 else None
|
|
1422
|
+
tasks = {}
|
|
1423
|
+
for a, project in projects.items():
|
|
1424
|
+
if server_url:
|
|
1425
|
+
coro = _http_call_tool(
|
|
1426
|
+
server_url,
|
|
1427
|
+
project,
|
|
1428
|
+
tool_name,
|
|
1429
|
+
tool_input,
|
|
1430
|
+
branch_id=branch_id,
|
|
1431
|
+
)
|
|
1432
|
+
else:
|
|
1433
|
+
coro = _connect_and_call_tool(
|
|
1434
|
+
project,
|
|
1435
|
+
tool_name,
|
|
1436
|
+
tool_input,
|
|
1437
|
+
branch_id=branch_id,
|
|
1438
|
+
)
|
|
1439
|
+
if sem is not None:
|
|
1440
|
+
coro = _semaphored(sem, coro)
|
|
1441
|
+
tasks[a] = asyncio.create_task(coro)
|
|
1442
|
+
return await self._gather_results(tasks)
|
|
1443
|
+
|
|
1444
|
+
def check_server_available(self) -> dict[str, Any]:
|
|
1445
|
+
"""Check if MCP server is available (for doctor command).
|
|
1446
|
+
|
|
1447
|
+
Returns:
|
|
1448
|
+
Dict with check status, message, and transport info.
|
|
1449
|
+
"""
|
|
1450
|
+
command = detect_mcp_server_command()
|
|
1451
|
+
if command is None:
|
|
1452
|
+
return {
|
|
1453
|
+
"check": "mcp_server",
|
|
1454
|
+
"name": "MCP server",
|
|
1455
|
+
"status": "warn",
|
|
1456
|
+
"message": (
|
|
1457
|
+
"keboola-mcp-server not found. "
|
|
1458
|
+
"Install with: pip install keboola-mcp-server "
|
|
1459
|
+
"(or use uvx keboola_mcp_server). "
|
|
1460
|
+
"MCP tool commands will not work without it."
|
|
1461
|
+
),
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
transport_mode = _get_transport_mode()
|
|
1465
|
+
transport_info = f"transport={transport_mode}"
|
|
1466
|
+
|
|
1467
|
+
# Detect if using slow uvx fallback
|
|
1468
|
+
is_uvx_fallback = command[0] == "uvx"
|
|
1469
|
+
if is_uvx_fallback:
|
|
1470
|
+
transport_info += ", using uvx (slower startup)"
|
|
1471
|
+
|
|
1472
|
+
# If HTTP mode, check persistent server status
|
|
1473
|
+
if transport_mode == "http":
|
|
1474
|
+
try:
|
|
1475
|
+
from .mcp_transport import get_server_manager
|
|
1476
|
+
|
|
1477
|
+
manager = get_server_manager()
|
|
1478
|
+
if manager.is_running:
|
|
1479
|
+
transport_info += f", persistent server running on port {manager.port}"
|
|
1480
|
+
else:
|
|
1481
|
+
transport_info += ", persistent server not yet started (lazy start)"
|
|
1482
|
+
except Exception:
|
|
1483
|
+
transport_info += ", persistent server unavailable (will fallback to stdio)"
|
|
1484
|
+
|
|
1485
|
+
status = "pass"
|
|
1486
|
+
message = f"MCP server available via: {' '.join(command)} ({transport_info})"
|
|
1487
|
+
if is_uvx_fallback:
|
|
1488
|
+
status = "warn"
|
|
1489
|
+
# --prerelease=allow is required (issue #324); a copied hint
|
|
1490
|
+
# without it backtracks to the stale v1.32.0 and exits 0.
|
|
1491
|
+
message += f". For faster startup, run: uv tool install {MCP_UV_PRERELEASE_FLAG} keboola-mcp-server"
|
|
1492
|
+
|
|
1493
|
+
return {
|
|
1494
|
+
"check": "mcp_server",
|
|
1495
|
+
"name": "MCP server",
|
|
1496
|
+
"status": status,
|
|
1497
|
+
"message": message,
|
|
1498
|
+
}
|