datajunction 0.0.147__tar.gz → 0.0.148__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.
- {datajunction-0.0.147 → datajunction-0.0.148}/PKG-INFO +1 -2
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/__about__.py +1 -1
- datajunction-0.0.148/datajunction/mcp/__init__.py +12 -0
- datajunction-0.0.148/datajunction/mcp/cli.py +135 -0
- datajunction-0.0.148/datajunction/mcp/config.py +71 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/pyproject.toml +5 -2
- datajunction-0.0.148/tests/mcp/README.md +16 -0
- datajunction-0.0.148/tests/mcp/test_cli.py +285 -0
- datajunction-0.0.147/datajunction/mcp/__init__.py +0 -6
- datajunction-0.0.147/datajunction/mcp/cli.py +0 -52
- datajunction-0.0.147/datajunction/mcp/config.py +0 -27
- datajunction-0.0.147/datajunction/mcp/formatters.py +0 -289
- datajunction-0.0.147/datajunction/mcp/server.py +0 -554
- datajunction-0.0.147/datajunction/mcp/tools.py +0 -1492
- datajunction-0.0.147/tests/mcp/README.md +0 -99
- datajunction-0.0.147/tests/mcp/test_cli.py +0 -102
- datajunction-0.0.147/tests/mcp/test_formatters.py +0 -454
- datajunction-0.0.147/tests/mcp/test_server_tools.py +0 -585
- datajunction-0.0.147/tests/mcp/test_tools.py +0 -3119
- datajunction-0.0.147/tests/mcp/test_visualize_metrics.py +0 -1491
- {datajunction-0.0.147 → datajunction-0.0.148}/.coveragerc +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/.gitignore +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/.pre-commit-config.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/LICENSE.txt +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/Makefile +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/README.md +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/claude_desktop_config.example.json +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/__init__.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/_base.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/_internal.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/admin.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/builder.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/cli.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/client.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/compile.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/deployment.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/exceptions.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/models.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/nodes.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/rendering.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/init_system_nodes.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/date.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/dimension_link.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/is_active.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/materialization.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/node_type.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/node_without_description.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/nodes.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/number_of_materializations.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/number_of_nodes.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/seed/nodes/user.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/skills/datajunction.md +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/datajunction/tags.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/setup.cfg +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/__init__.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/conftest.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/companies.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/companies_dim.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/contractor.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/contractors.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/us_state.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/deploy0/roads/us_states.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/avg_length_of_employment.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/avg_repair_price.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/avg_time_to_dispatch.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/date.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/date_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/dispatcher.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/dispatchers.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/hard_hat.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/hard_hat_state.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/hard_hats.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/local_hard_hats.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/municipality.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/municipality_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/municipality_municipality_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/municipality_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/national_level_agg.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/num_repair_orders.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/regional_level_agg.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/regional_repair_efficiency.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_order.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_order_details.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_order_transform.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_orders.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_orders_cube.cube.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/repair_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/total_repair_cost.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/total_repair_order_discounts.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/us_region.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/us_state.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project1/roads/us_states.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project10/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/avg_length_of_employment.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/avg_repair_price.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/avg_time_to_dispatch.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/date.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/date_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/dispatcher.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/dispatchers.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/hard_hat.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/hard_hat_state.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/hard_hats.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/local_hard_hats.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/municipality.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/municipality_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/municipality_municipality_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/municipality_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/national_level_agg.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/num_repair_orders.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/regional_level_agg.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/regional_repair_efficiency.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_order.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_order_details.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_order_transform.transform.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_orders.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_orders_cube.cube.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/repair_type.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/total_repair_cost.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/total_repair_order_discounts.metric.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/us_region.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/us_state.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project11/us_states.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/companies.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/companies_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/us_state.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project12/roads/us_states.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project2/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project2/some_node.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project3/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project3/some_node.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project4/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project4/very/very/deeply/nested/namespace/some_node.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project5/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project5/some_node.a.b.c.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project6/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project6/roads/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project6/roads/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project7/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project7/roads/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project7/roads/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project8/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/dj.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/companies.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/companies_dim.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/contractor.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/contractors.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/us_state.dimension.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples/project9/roads/us_states.source.yaml +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/examples.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/mcp/__init__.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test__internal.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_admin.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_base.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_builder.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_cli.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_client.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_compile.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_deploy.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_generated_client.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_integration.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tests/test_models.py +0 -0
- {datajunction-0.0.147 → datajunction-0.0.148}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datajunction
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.148
|
|
4
4
|
Summary: DataJunction client library for connecting to a DataJunction server
|
|
5
5
|
Project-URL: repository, https://github.com/DataJunction/dj
|
|
6
6
|
Author-email: DataJunction Authors <yian.shang@gmail.com>
|
|
@@ -20,7 +20,6 @@ Requires-Dist: requests<3.0.0,>=2.28.2
|
|
|
20
20
|
Requires-Dist: rich>=13.7.0
|
|
21
21
|
Provides-Extra: mcp
|
|
22
22
|
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
23
|
-
Requires-Dist: plotext>=5.2.8; extra == 'mcp'
|
|
24
23
|
Requires-Dist: pydantic-settings>=2.10.1; extra == 'mcp'
|
|
25
24
|
Requires-Dist: pydantic>=2.0; extra == 'mcp'
|
|
26
25
|
Provides-Extra: pandas
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DataJunction MCP stdio→HTTP proxy.
|
|
3
|
+
|
|
4
|
+
The canonical MCP server is hosted by ``datajunction-server`` at the
|
|
5
|
+
``/mcp`` endpoint (Streamable HTTP transport). This package provides a
|
|
6
|
+
small ``dj-mcp`` CLI that bridges stdio MCP clients (e.g. Claude Desktop)
|
|
7
|
+
to the hosted server, so users on stdio-only clients can still talk to a
|
|
8
|
+
deployed DJ.
|
|
9
|
+
|
|
10
|
+
For HTTP-capable clients (Claude Code, Cursor, Slack agents) connect to
|
|
11
|
+
the hosted ``/mcp`` endpoint directly — no local CLI required.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stdio→HTTP proxy for the DataJunction MCP server.
|
|
3
|
+
|
|
4
|
+
The server-side MCP implementation now lives in ``datajunction-server``
|
|
5
|
+
(`/mcp` endpoint, Streamable HTTP transport). This CLI exists so users on
|
|
6
|
+
clients that only speak stdio (e.g. Claude Desktop today) can still talk
|
|
7
|
+
to a hosted DJ MCP without running anything heavyweight locally.
|
|
8
|
+
|
|
9
|
+
It opens an HTTP MCP client to the configured hosted endpoint, hosts a
|
|
10
|
+
stdio MCP server, and forwards every list_tools / call_tool / list_resources /
|
|
11
|
+
read_resource request to the upstream. The wire shape is identical, so
|
|
12
|
+
clients see the same tool surface as the hosted server exposes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import mcp.types as types
|
|
22
|
+
from mcp.client.session import ClientSession
|
|
23
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
24
|
+
from mcp.server import Server
|
|
25
|
+
from mcp.server.stdio import stdio_server
|
|
26
|
+
|
|
27
|
+
from datajunction.mcp.config import get_mcp_settings
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO,
|
|
31
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
32
|
+
stream=sys.stderr, # stdout is reserved for MCP protocol traffic
|
|
33
|
+
)
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _proxy_list_tools(upstream: ClientSession) -> list[types.Tool]:
|
|
38
|
+
"""Forward ``tools/list`` to the upstream session."""
|
|
39
|
+
result = await upstream.list_tools()
|
|
40
|
+
return list(result.tools)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _proxy_call_tool(
|
|
44
|
+
upstream: ClientSession,
|
|
45
|
+
name: str,
|
|
46
|
+
arguments: dict,
|
|
47
|
+
) -> list[types.ContentBlock]:
|
|
48
|
+
"""Forward ``tools/call`` to the upstream session.
|
|
49
|
+
|
|
50
|
+
``result.content`` may be a tuple/list of content blocks; normalize to list.
|
|
51
|
+
"""
|
|
52
|
+
result = await upstream.call_tool(name, arguments=arguments or {})
|
|
53
|
+
return list(result.content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _proxy_list_resources(upstream: ClientSession) -> list[types.Resource]:
|
|
57
|
+
"""Forward ``resources/list`` to the upstream session."""
|
|
58
|
+
result = await upstream.list_resources()
|
|
59
|
+
return list(result.resources)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _proxy_read_resource(upstream: ClientSession, uri: str) -> str:
|
|
63
|
+
"""Forward ``resources/read`` and surface the first text payload.
|
|
64
|
+
|
|
65
|
+
The hosted DJ doesn't expose any resources today, but if it ever does
|
|
66
|
+
we return the first ``text`` content block.
|
|
67
|
+
"""
|
|
68
|
+
result = await upstream.read_resource(uri) # type: ignore[arg-type]
|
|
69
|
+
for block in result.contents:
|
|
70
|
+
text = getattr(block, "text", None)
|
|
71
|
+
if text is not None:
|
|
72
|
+
return text
|
|
73
|
+
return "" # pragma: no cover
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_proxy_app(upstream: ClientSession) -> Server:
|
|
77
|
+
"""Create an MCP ``Server`` whose handlers all forward to ``upstream``.
|
|
78
|
+
|
|
79
|
+
Split out from ``_serve`` so the wiring is testable without spinning
|
|
80
|
+
up stdio.
|
|
81
|
+
"""
|
|
82
|
+
app: Server = Server("datajunction")
|
|
83
|
+
app.list_tools()(lambda: _proxy_list_tools(upstream))
|
|
84
|
+
app.call_tool()(
|
|
85
|
+
lambda name, arguments: _proxy_call_tool(upstream, name, arguments),
|
|
86
|
+
)
|
|
87
|
+
app.list_resources()(lambda: _proxy_list_resources(upstream))
|
|
88
|
+
app.read_resource()(lambda uri: _proxy_read_resource(upstream, uri))
|
|
89
|
+
return app
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _serve(upstream: ClientSession) -> None:
|
|
93
|
+
"""Run a stdio MCP server that forwards every request to ``upstream``."""
|
|
94
|
+
app = _build_proxy_app(upstream)
|
|
95
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
96
|
+
await app.run(
|
|
97
|
+
read_stream,
|
|
98
|
+
write_stream,
|
|
99
|
+
app.create_initialization_options(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def main() -> None:
|
|
104
|
+
"""Connect to the hosted DJ MCP and bridge it to stdio."""
|
|
105
|
+
settings = get_mcp_settings()
|
|
106
|
+
|
|
107
|
+
headers = {}
|
|
108
|
+
if settings.dj_api_token:
|
|
109
|
+
headers["Authorization"] = f"Bearer {settings.dj_api_token}"
|
|
110
|
+
|
|
111
|
+
logger.info("Bridging stdio MCP → %s", settings.mcp_url)
|
|
112
|
+
|
|
113
|
+
async with streamablehttp_client(
|
|
114
|
+
url=settings.mcp_url,
|
|
115
|
+
headers=headers or None,
|
|
116
|
+
timeout=settings.request_timeout,
|
|
117
|
+
) as (read, write, _get_session_id):
|
|
118
|
+
async with ClientSession(read, write) as upstream:
|
|
119
|
+
await upstream.initialize()
|
|
120
|
+
await _serve(upstream)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run() -> None:
|
|
124
|
+
"""Synchronous entrypoint for ``dj-mcp`` script registration."""
|
|
125
|
+
try:
|
|
126
|
+
asyncio.run(main())
|
|
127
|
+
except KeyboardInterrupt:
|
|
128
|
+
logger.info("dj-mcp stopped by user")
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
logger.error("dj-mcp error: %s", exc, exc_info=True)
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
run()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for the DataJunction MCP stdio→HTTP proxy CLI.
|
|
3
|
+
|
|
4
|
+
The CLI connects to a hosted DJ ``/mcp`` endpoint over Streamable HTTP and
|
|
5
|
+
re-exposes its tools to local stdio MCP clients (e.g. Claude Desktop).
|
|
6
|
+
|
|
7
|
+
Settings are read from env on every ``get_mcp_settings()`` call (no caching),
|
|
8
|
+
so override behavior is predictable in tests and shell-set values are picked
|
|
9
|
+
up at startup.
|
|
10
|
+
|
|
11
|
+
Resolution order for the upstream MCP URL:
|
|
12
|
+
|
|
13
|
+
1. ``DJ_MCP_URL`` if set — full URL of the ``/mcp`` endpoint.
|
|
14
|
+
2. ``DJ_API_URL`` if set — backwards-compatible fallback for users of the
|
|
15
|
+
pre-migration ``dj-mcp`` CLI. We append ``/mcp`` to it.
|
|
16
|
+
3. ``http://localhost:8000/mcp`` — local-dev default.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from pydantic import Field
|
|
25
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_default_mcp_url() -> str:
|
|
29
|
+
"""Fall back to the legacy ``DJ_API_URL`` env var when ``DJ_MCP_URL``
|
|
30
|
+
is unset.
|
|
31
|
+
|
|
32
|
+
Pre-migration deployments configured the stdio CLI with ``DJ_API_URL``
|
|
33
|
+
(the base DJ API URL); the new CLI talks directly to ``/mcp``. We read
|
|
34
|
+
``DJ_API_URL`` and append the path so existing setups don't silently
|
|
35
|
+
break.
|
|
36
|
+
|
|
37
|
+
Pydantic-settings reads ``DJ_MCP_URL`` via the field alias before this
|
|
38
|
+
factory fires, so we only need to handle the legacy + default cases.
|
|
39
|
+
"""
|
|
40
|
+
legacy = os.environ.get("DJ_API_URL")
|
|
41
|
+
if legacy:
|
|
42
|
+
return legacy.rstrip("/") + "/mcp"
|
|
43
|
+
return "http://localhost:8000/mcp"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MCPSettings(BaseSettings):
|
|
47
|
+
"""Settings for the dj-mcp stdio CLI."""
|
|
48
|
+
|
|
49
|
+
# Hosted DJ MCP endpoint. Override with ``DJ_MCP_URL``; ``DJ_API_URL``
|
|
50
|
+
# is accepted as a backwards-compatible fallback (see module docstring).
|
|
51
|
+
mcp_url: str = Field(
|
|
52
|
+
default_factory=_resolve_default_mcp_url,
|
|
53
|
+
alias="DJ_MCP_URL",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Bearer token forwarded as Authorization on every upstream request.
|
|
57
|
+
# Use ``DJ_API_TOKEN`` to align with the rest of the DJ tooling.
|
|
58
|
+
dj_api_token: Optional[str] = Field(default=None, alias="DJ_API_TOKEN")
|
|
59
|
+
|
|
60
|
+
# Request timeout for upstream calls.
|
|
61
|
+
request_timeout: float = 30.0
|
|
62
|
+
|
|
63
|
+
model_config = SettingsConfigDict(
|
|
64
|
+
case_sensitive=False,
|
|
65
|
+
populate_by_name=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_mcp_settings() -> MCPSettings:
|
|
70
|
+
"""Return MCP settings. Re-reads env on every call."""
|
|
71
|
+
return MCPSettings()
|
|
@@ -30,11 +30,14 @@ classifiers = [
|
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
32
|
pandas = ["pandas>=2.0.2"]
|
|
33
|
+
# Optional dependencies for the dj-mcp stdio→HTTP proxy CLI. plotext was
|
|
34
|
+
# previously needed for client-side rendering of `visualize_metrics`; that
|
|
35
|
+
# logic now lives server-side in datajunction-server, so the client only
|
|
36
|
+
# needs the MCP SDK + pydantic-settings to read env-var configuration.
|
|
33
37
|
mcp = [
|
|
34
38
|
"mcp>=1.0.0",
|
|
35
39
|
"pydantic>=2.0",
|
|
36
40
|
"pydantic-settings>=2.10.1",
|
|
37
|
-
"plotext>=5.2.8",
|
|
38
41
|
]
|
|
39
42
|
|
|
40
43
|
[tool.hatch.version]
|
|
@@ -90,5 +93,5 @@ test = [
|
|
|
90
93
|
"nbformat>=5.10.4",
|
|
91
94
|
"sqlglot>=18.0.1",
|
|
92
95
|
"pydantic-settings>=2.11.0",
|
|
93
|
-
"mcp>=1.0.0", # For
|
|
96
|
+
"mcp>=1.0.0", # For dj-mcp proxy CLI tests
|
|
94
97
|
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# dj-mcp CLI tests
|
|
2
|
+
|
|
3
|
+
The DataJunction MCP server is hosted in `datajunction-server` at the
|
|
4
|
+
`/mcp` endpoint (Streamable HTTP transport). The client package only
|
|
5
|
+
ships a thin stdio→HTTP proxy CLI (`dj-mcp`) for stdio-only MCP clients
|
|
6
|
+
like Claude Desktop.
|
|
7
|
+
|
|
8
|
+
These tests cover the proxy wiring:
|
|
9
|
+
|
|
10
|
+
- env vars (`DJ_API_TOKEN`, `DJ_MCP_URL`) → upstream URL + Authorization header
|
|
11
|
+
- `run()` invokes `asyncio.run(main())`
|
|
12
|
+
|
|
13
|
+
End-to-end tool behavior is covered server-side under
|
|
14
|
+
`datajunction-server/tests/dj_mcp/`. There's no per-tool coverage here
|
|
15
|
+
because the CLI doesn't implement tools — it forwards every request to
|
|
16
|
+
the hosted server.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the dj-mcp stdio→HTTP proxy CLI.
|
|
3
|
+
|
|
4
|
+
The CLI delegates everything to a hosted DJ MCP server via Streamable HTTP.
|
|
5
|
+
We just verify the wiring: settings → headers, upstream connect, stdio
|
|
6
|
+
bridge runs. Actual tool behavior is tested in datajunction-server's
|
|
7
|
+
``tests/dj_mcp/`` suite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
11
|
+
|
|
12
|
+
import mcp.types as types
|
|
13
|
+
import pytest
|
|
14
|
+
from mcp.server import Server
|
|
15
|
+
|
|
16
|
+
from datajunction.mcp import cli
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_dj_api_url_backwards_compat(monkeypatch) -> None:
|
|
20
|
+
"""Pre-migration deployments set ``DJ_API_URL``; the new CLI honours
|
|
21
|
+
it and appends ``/mcp`` so existing setups don't silently break."""
|
|
22
|
+
from datajunction.mcp.config import get_mcp_settings
|
|
23
|
+
|
|
24
|
+
monkeypatch.delenv("DJ_MCP_URL", raising=False)
|
|
25
|
+
monkeypatch.setenv("DJ_API_URL", "https://dj.example.com")
|
|
26
|
+
assert get_mcp_settings().mcp_url == "https://dj.example.com/mcp"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_dj_mcp_url_takes_precedence_over_dj_api_url(monkeypatch) -> None:
|
|
30
|
+
"""If both are set, ``DJ_MCP_URL`` wins — no surprise when explicitly configured."""
|
|
31
|
+
from datajunction.mcp.config import get_mcp_settings
|
|
32
|
+
|
|
33
|
+
monkeypatch.setenv("DJ_MCP_URL", "https://override/mcp")
|
|
34
|
+
monkeypatch.setenv("DJ_API_URL", "https://legacy")
|
|
35
|
+
assert get_mcp_settings().mcp_url == "https://override/mcp"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_default_mcp_url_when_neither_set(monkeypatch) -> None:
|
|
39
|
+
"""No env vars → localhost default."""
|
|
40
|
+
from datajunction.mcp.config import get_mcp_settings
|
|
41
|
+
|
|
42
|
+
monkeypatch.delenv("DJ_MCP_URL", raising=False)
|
|
43
|
+
monkeypatch.delenv("DJ_API_URL", raising=False)
|
|
44
|
+
assert get_mcp_settings().mcp_url == "http://localhost:8000/mcp"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_main_connects_with_bearer_token_when_set(monkeypatch):
|
|
49
|
+
"""When DJ_API_TOKEN is set, it's forwarded as an Authorization header."""
|
|
50
|
+
monkeypatch.setenv("DJ_API_TOKEN", "test-token")
|
|
51
|
+
monkeypatch.setenv("DJ_MCP_URL", "http://example.com/mcp")
|
|
52
|
+
|
|
53
|
+
captured: dict = {}
|
|
54
|
+
|
|
55
|
+
class _FakeStreamCtx:
|
|
56
|
+
async def __aenter__(self):
|
|
57
|
+
return (MagicMock(), MagicMock(), lambda: None)
|
|
58
|
+
|
|
59
|
+
async def __aexit__(self, *exc):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def fake_streamablehttp_client(url, headers=None, timeout=None):
|
|
63
|
+
captured["url"] = url
|
|
64
|
+
captured["headers"] = headers
|
|
65
|
+
return _FakeStreamCtx()
|
|
66
|
+
|
|
67
|
+
class _FakeSessionCtx:
|
|
68
|
+
def __init__(self, *a, **kw):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
async def __aenter__(self):
|
|
72
|
+
session = MagicMock()
|
|
73
|
+
session.initialize = AsyncMock()
|
|
74
|
+
return session
|
|
75
|
+
|
|
76
|
+
async def __aexit__(self, *exc):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr(cli, "streamablehttp_client", fake_streamablehttp_client)
|
|
80
|
+
monkeypatch.setattr(cli, "ClientSession", _FakeSessionCtx)
|
|
81
|
+
monkeypatch.setattr(cli, "_serve", AsyncMock())
|
|
82
|
+
|
|
83
|
+
await cli.main()
|
|
84
|
+
|
|
85
|
+
assert captured["url"] == "http://example.com/mcp"
|
|
86
|
+
assert captured["headers"] == {"Authorization": "Bearer test-token"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_main_without_token_omits_auth_header(monkeypatch):
|
|
91
|
+
"""No DJ_API_TOKEN → no Authorization header (None passed to httpx)."""
|
|
92
|
+
monkeypatch.delenv("DJ_API_TOKEN", raising=False)
|
|
93
|
+
monkeypatch.setenv("DJ_MCP_URL", "http://example.com/mcp")
|
|
94
|
+
|
|
95
|
+
captured: dict = {}
|
|
96
|
+
|
|
97
|
+
class _FakeStreamCtx:
|
|
98
|
+
async def __aenter__(self):
|
|
99
|
+
return (MagicMock(), MagicMock(), lambda: None)
|
|
100
|
+
|
|
101
|
+
async def __aexit__(self, *exc):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def fake_streamablehttp_client(url, headers=None, timeout=None):
|
|
105
|
+
captured["headers"] = headers
|
|
106
|
+
return _FakeStreamCtx()
|
|
107
|
+
|
|
108
|
+
class _FakeSessionCtx:
|
|
109
|
+
def __init__(self, *a, **kw):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
async def __aenter__(self):
|
|
113
|
+
session = MagicMock()
|
|
114
|
+
session.initialize = AsyncMock()
|
|
115
|
+
return session
|
|
116
|
+
|
|
117
|
+
async def __aexit__(self, *exc):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(cli, "streamablehttp_client", fake_streamablehttp_client)
|
|
121
|
+
monkeypatch.setattr(cli, "ClientSession", _FakeSessionCtx)
|
|
122
|
+
monkeypatch.setattr(cli, "_serve", AsyncMock())
|
|
123
|
+
|
|
124
|
+
await cli.main()
|
|
125
|
+
assert captured["headers"] is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_run_invokes_asyncio_run(monkeypatch):
|
|
129
|
+
"""``run()`` is the script entrypoint — it should drive ``main()`` via asyncio."""
|
|
130
|
+
|
|
131
|
+
invoked = {}
|
|
132
|
+
|
|
133
|
+
def fake_asyncio_run(coro):
|
|
134
|
+
invoked["called"] = True
|
|
135
|
+
coro.close() # avoid "coroutine was never awaited" warning
|
|
136
|
+
|
|
137
|
+
monkeypatch.setattr(cli.asyncio, "run", fake_asyncio_run)
|
|
138
|
+
cli.run()
|
|
139
|
+
assert invoked["called"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_run_swallows_keyboard_interrupt(monkeypatch):
|
|
143
|
+
"""``Ctrl-C`` in the CLI should log and exit cleanly, not propagate."""
|
|
144
|
+
|
|
145
|
+
def fake_asyncio_run(coro):
|
|
146
|
+
coro.close()
|
|
147
|
+
raise KeyboardInterrupt
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr(cli.asyncio, "run", fake_asyncio_run)
|
|
150
|
+
cli.run() # must not raise
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_run_logs_and_exits_on_unhandled_exception(monkeypatch):
|
|
154
|
+
"""Any other exception is logged and the process exits with code 1."""
|
|
155
|
+
|
|
156
|
+
def fake_asyncio_run(coro):
|
|
157
|
+
coro.close()
|
|
158
|
+
raise RuntimeError("boom")
|
|
159
|
+
|
|
160
|
+
exits: list = []
|
|
161
|
+
|
|
162
|
+
def fake_exit(code):
|
|
163
|
+
exits.append(code)
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(cli.asyncio, "run", fake_asyncio_run)
|
|
166
|
+
monkeypatch.setattr(cli.sys, "exit", fake_exit)
|
|
167
|
+
cli.run()
|
|
168
|
+
assert exits == [1]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Proxy handlers — verify each MCP method forwards to the upstream session
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.asyncio
|
|
177
|
+
async def test_proxy_list_tools_forwards_to_upstream():
|
|
178
|
+
"""``list_tools`` returns the upstream's tools verbatim, normalised to list."""
|
|
179
|
+
|
|
180
|
+
upstream = MagicMock()
|
|
181
|
+
upstream.list_tools = AsyncMock(
|
|
182
|
+
return_value=MagicMock(
|
|
183
|
+
tools=(
|
|
184
|
+
types.Tool(name="foo", description="d", inputSchema={"type": "object"}),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
out = await cli._proxy_list_tools(upstream)
|
|
190
|
+
assert [t.name for t in out] == ["foo"]
|
|
191
|
+
upstream.list_tools.assert_awaited_once()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_proxy_call_tool_forwards_arguments():
|
|
196
|
+
"""``call_tool`` forwards (name, arguments) and returns the content list."""
|
|
197
|
+
|
|
198
|
+
upstream = MagicMock()
|
|
199
|
+
block = types.TextContent(type="text", text="ok")
|
|
200
|
+
upstream.call_tool = AsyncMock(return_value=MagicMock(content=(block,)))
|
|
201
|
+
|
|
202
|
+
out = await cli._proxy_call_tool(upstream, "foo", {"k": "v"})
|
|
203
|
+
assert out == [block]
|
|
204
|
+
upstream.call_tool.assert_awaited_once_with("foo", arguments={"k": "v"})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@pytest.mark.asyncio
|
|
208
|
+
async def test_proxy_call_tool_normalises_none_arguments():
|
|
209
|
+
"""If ``arguments`` is None it's coerced to an empty dict before forwarding."""
|
|
210
|
+
|
|
211
|
+
upstream = MagicMock()
|
|
212
|
+
upstream.call_tool = AsyncMock(return_value=MagicMock(content=()))
|
|
213
|
+
|
|
214
|
+
out = await cli._proxy_call_tool(upstream, "foo", None) # type: ignore[arg-type]
|
|
215
|
+
assert out == []
|
|
216
|
+
upstream.call_tool.assert_awaited_once_with("foo", arguments={})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.asyncio
|
|
220
|
+
async def test_proxy_list_resources_forwards_to_upstream():
|
|
221
|
+
"""``list_resources`` returns the upstream's resources verbatim."""
|
|
222
|
+
|
|
223
|
+
upstream = MagicMock()
|
|
224
|
+
res = types.Resource(uri="dj://x", name="x")
|
|
225
|
+
upstream.list_resources = AsyncMock(return_value=MagicMock(resources=(res,)))
|
|
226
|
+
|
|
227
|
+
out = await cli._proxy_list_resources(upstream)
|
|
228
|
+
assert out == [res]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_proxy_read_resource_returns_first_text_block():
|
|
233
|
+
"""``read_resource`` surfaces the first ``text`` block in the upstream response."""
|
|
234
|
+
|
|
235
|
+
upstream = MagicMock()
|
|
236
|
+
block_no_text = MagicMock(spec=[]) # no .text attribute
|
|
237
|
+
block_with_text = MagicMock()
|
|
238
|
+
block_with_text.text = "hello"
|
|
239
|
+
upstream.read_resource = AsyncMock(
|
|
240
|
+
return_value=MagicMock(contents=[block_no_text, block_with_text]),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
out = await cli._proxy_read_resource(upstream, "dj://x")
|
|
244
|
+
assert out == "hello"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_build_proxy_app_registers_all_four_handlers():
|
|
249
|
+
"""``_build_proxy_app`` returns a Server with handlers for every MCP method."""
|
|
250
|
+
|
|
251
|
+
upstream = MagicMock()
|
|
252
|
+
app = cli._build_proxy_app(upstream)
|
|
253
|
+
assert isinstance(app, Server)
|
|
254
|
+
# The MCP SDK stores registered handlers in ``request_handlers`` keyed by
|
|
255
|
+
# the request type. We just need to confirm all four are wired.
|
|
256
|
+
handler_count = len(app.request_handlers)
|
|
257
|
+
assert handler_count >= 4
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
async def test_serve_drives_app_run_with_stdio_streams(monkeypatch):
|
|
262
|
+
"""``_serve`` opens stdio_server, builds the proxy app, runs it. Stub
|
|
263
|
+
everything so we just verify the wiring."""
|
|
264
|
+
|
|
265
|
+
sentinel_app = MagicMock()
|
|
266
|
+
sentinel_app.run = AsyncMock()
|
|
267
|
+
sentinel_app.create_initialization_options = MagicMock(return_value="opts")
|
|
268
|
+
|
|
269
|
+
monkeypatch.setattr(cli, "_build_proxy_app", lambda upstream: sentinel_app)
|
|
270
|
+
|
|
271
|
+
class _StdioCtx:
|
|
272
|
+
async def __aenter__(self):
|
|
273
|
+
return ("read-stream", "write-stream")
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, *exc):
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
monkeypatch.setattr(cli, "stdio_server", lambda: _StdioCtx())
|
|
279
|
+
|
|
280
|
+
await cli._serve(MagicMock())
|
|
281
|
+
sentinel_app.run.assert_awaited_once_with(
|
|
282
|
+
"read-stream",
|
|
283
|
+
"write-stream",
|
|
284
|
+
"opts",
|
|
285
|
+
)
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
CLI entry point for DataJunction MCP Server
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import logging
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from mcp.server.stdio import stdio_server
|
|
10
|
-
|
|
11
|
-
from datajunction.mcp.server import app
|
|
12
|
-
from datajunction.mcp.config import get_mcp_settings
|
|
13
|
-
|
|
14
|
-
# Configure logging
|
|
15
|
-
logging.basicConfig(
|
|
16
|
-
level=logging.INFO,
|
|
17
|
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
18
|
-
stream=sys.stderr, # Log to stderr so stdout is clean for MCP protocol
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async def main():
|
|
25
|
-
"""Main entry point for MCP server"""
|
|
26
|
-
settings = get_mcp_settings()
|
|
27
|
-
|
|
28
|
-
logger.info("Starting DataJunction MCP Server")
|
|
29
|
-
logger.info(f"Connecting to DJ API at: {settings.dj_api_url}")
|
|
30
|
-
|
|
31
|
-
# Run the MCP server using stdio transport
|
|
32
|
-
async with stdio_server() as (read_stream, write_stream):
|
|
33
|
-
await app.run(
|
|
34
|
-
read_stream,
|
|
35
|
-
write_stream,
|
|
36
|
-
app.create_initialization_options(),
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def run():
|
|
41
|
-
"""Synchronous wrapper for async main"""
|
|
42
|
-
try:
|
|
43
|
-
asyncio.run(main())
|
|
44
|
-
except KeyboardInterrupt:
|
|
45
|
-
logger.info("MCP Server stopped by user")
|
|
46
|
-
except Exception as e:
|
|
47
|
-
logger.error(f"MCP Server error: {str(e)}", exc_info=True)
|
|
48
|
-
sys.exit(1)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if __name__ == "__main__":
|
|
52
|
-
run()
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Configuration for DataJunction MCP Server
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from typing import Optional
|
|
7
|
-
from pydantic_settings import BaseSettings
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class MCPSettings(BaseSettings):
|
|
11
|
-
"""Settings for MCP server"""
|
|
12
|
-
|
|
13
|
-
# DJ API connection
|
|
14
|
-
dj_api_url: str = os.getenv("DJ_API_URL", "http://localhost:8000")
|
|
15
|
-
dj_api_token: Optional[str] = os.getenv("DJ_API_TOKEN")
|
|
16
|
-
|
|
17
|
-
# Optional: Basic auth (if not using token)
|
|
18
|
-
dj_username: Optional[str] = os.getenv("DJ_USERNAME")
|
|
19
|
-
dj_password: Optional[str] = os.getenv("DJ_PASSWORD")
|
|
20
|
-
|
|
21
|
-
# Request timeout
|
|
22
|
-
request_timeout: int = 30
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def get_mcp_settings() -> MCPSettings:
|
|
26
|
-
"""Get MCP settings singleton"""
|
|
27
|
-
return MCPSettings()
|