recce-nightly 1.23.0.20251029__py3-none-any.whl → 1.23.0.20251030__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.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

recce/mcp_server.py ADDED
@@ -0,0 +1,276 @@
1
+ """
2
+ Recce MCP (Model Context Protocol) Server
3
+
4
+ This module implements a stdio-based MCP server that provides tools for
5
+ interacting with Recce's data validation capabilities.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ from typing import Any, Dict, List
12
+
13
+ from mcp.server import Server
14
+ from mcp.server.stdio import stdio_server
15
+ from mcp.types import TextContent, Tool
16
+
17
+ from recce.core import RecceContext, load_context
18
+ from recce.tasks.profile import ProfileDiffTask
19
+ from recce.tasks.query import QueryDiffTask, QueryTask
20
+ from recce.tasks.rowcount import RowCountDiffTask
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class RecceMCPServer:
26
+ """MCP Server for Recce data validation tools"""
27
+
28
+ def __init__(self, context: RecceContext):
29
+ self.context = context
30
+ self.server = Server("recce")
31
+ self._setup_handlers()
32
+
33
+ def _setup_handlers(self):
34
+ """Register all tool handlers"""
35
+
36
+ @self.server.list_tools()
37
+ async def list_tools() -> List[Tool]:
38
+ """List all available tools"""
39
+ return [
40
+ Tool(
41
+ name="get_lineage_diff",
42
+ description="Get the lineage diff between base and current environments. "
43
+ "Shows added, removed, and modified models.",
44
+ inputSchema={
45
+ "type": "object",
46
+ "properties": {
47
+ "select": {
48
+ "type": "string",
49
+ "description": "dbt selector syntax to filter models (optional)",
50
+ },
51
+ "exclude": {
52
+ "type": "string",
53
+ "description": "dbt selector syntax to exclude models (optional)",
54
+ },
55
+ "packages": {
56
+ "type": "array",
57
+ "items": {"type": "string"},
58
+ "description": "List of packages to filter (optional)",
59
+ },
60
+ },
61
+ },
62
+ ),
63
+ Tool(
64
+ name="row_count_diff",
65
+ description="Compare row counts between base and current environments for specified models.",
66
+ inputSchema={
67
+ "type": "object",
68
+ "properties": {
69
+ "node_names": {
70
+ "type": "array",
71
+ "items": {"type": "string"},
72
+ "description": "List of model names to check row counts (optional)",
73
+ },
74
+ "node_ids": {
75
+ "type": "array",
76
+ "items": {"type": "string"},
77
+ "description": "List of node IDs to check row counts (optional)",
78
+ },
79
+ "select": {
80
+ "type": "string",
81
+ "description": "dbt selector syntax to filter models (optional)",
82
+ },
83
+ "exclude": {
84
+ "type": "string",
85
+ "description": "dbt selector syntax to exclude models (optional)",
86
+ },
87
+ },
88
+ },
89
+ ),
90
+ Tool(
91
+ name="query",
92
+ description="Execute a SQL query on the current environment. "
93
+ "Supports Jinja templates with dbt macros like {{ ref('model_name') }}.",
94
+ inputSchema={
95
+ "type": "object",
96
+ "properties": {
97
+ "sql_template": {
98
+ "type": "string",
99
+ "description": "SQL query template with optional Jinja syntax",
100
+ },
101
+ "base": {
102
+ "type": "boolean",
103
+ "description": "Whether to run on base environment (default: false)",
104
+ "default": False,
105
+ },
106
+ },
107
+ "required": ["sql_template"],
108
+ },
109
+ ),
110
+ Tool(
111
+ name="query_diff",
112
+ description="Execute SQL queries on both base and current environments and compare results. "
113
+ "Supports primary keys for row-level comparison.",
114
+ inputSchema={
115
+ "type": "object",
116
+ "properties": {
117
+ "sql_template": {
118
+ "type": "string",
119
+ "description": "SQL query template for current environment",
120
+ },
121
+ "base_sql_template": {
122
+ "type": "string",
123
+ "description": "SQL query template for base environment (optional, defaults to sql_template)",
124
+ },
125
+ "primary_keys": {
126
+ "type": "array",
127
+ "items": {"type": "string"},
128
+ "description": "List of primary key columns for row comparison (optional)",
129
+ },
130
+ },
131
+ "required": ["sql_template"],
132
+ },
133
+ ),
134
+ Tool(
135
+ name="profile_diff",
136
+ description="Generate and compare statistical profiles (min, max, avg, distinct count, etc.) "
137
+ "for columns in a model between base and current environments.",
138
+ inputSchema={
139
+ "type": "object",
140
+ "properties": {
141
+ "model": {
142
+ "type": "string",
143
+ "description": "Model name to profile",
144
+ },
145
+ "columns": {
146
+ "type": "array",
147
+ "items": {"type": "string"},
148
+ "description": "List of column names to profile (optional, profiles all columns if not specified)",
149
+ },
150
+ },
151
+ "required": ["model"],
152
+ },
153
+ ),
154
+ ]
155
+
156
+ @self.server.call_tool()
157
+ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
158
+ """Handle tool calls"""
159
+ try:
160
+ if name == "get_lineage_diff":
161
+ result = await self._get_lineage_diff(arguments)
162
+ elif name == "row_count_diff":
163
+ result = await self._row_count_diff(arguments)
164
+ elif name == "query":
165
+ result = await self._query(arguments)
166
+ elif name == "query_diff":
167
+ result = await self._query_diff(arguments)
168
+ elif name == "profile_diff":
169
+ result = await self._profile_diff(arguments)
170
+ else:
171
+ raise ValueError(f"Unknown tool: {name}")
172
+
173
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
174
+ except Exception as e:
175
+ logger.exception(f"Error executing tool {name}")
176
+ return [TextContent(type="text", text=json.dumps({"error": str(e)}, indent=2))]
177
+
178
+ async def _get_lineage_diff(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
179
+ """Get lineage diff between base and current"""
180
+ try:
181
+ # Get lineage diff from adapter (returns a Pydantic LineageDiff model)
182
+ lineage_diff = self.context.get_lineage_diff()
183
+ # Convert Pydantic model to dict for JSON serialization
184
+ return lineage_diff.model_dump(mode="json")
185
+ except Exception:
186
+ logger.exception("Error getting lineage diff")
187
+ raise
188
+
189
+ async def _row_count_diff(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
190
+ """Execute row count diff task"""
191
+ try:
192
+ task = RowCountDiffTask(params=arguments)
193
+
194
+ # Execute task synchronously (it's already sync)
195
+ result = await asyncio.get_event_loop().run_in_executor(None, task.execute)
196
+
197
+ return result
198
+ except Exception:
199
+ logger.exception("Error executing row count diff")
200
+ raise
201
+
202
+ async def _query(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
203
+ """Execute a query"""
204
+ try:
205
+ sql_template = arguments.get("sql_template")
206
+ is_base = arguments.get("base", False)
207
+
208
+ params = {"sql_template": sql_template}
209
+ task = QueryTask(params=params)
210
+ task.is_base = is_base
211
+
212
+ # Execute task
213
+ result = await asyncio.get_event_loop().run_in_executor(None, task.execute)
214
+
215
+ # Convert to dict if it's a model
216
+ if hasattr(result, "model_dump"):
217
+ return result.model_dump(mode="json")
218
+ return result
219
+ except Exception:
220
+ logger.exception("Error executing query")
221
+ raise
222
+
223
+ async def _query_diff(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
224
+ """Execute query diff task"""
225
+ try:
226
+ task = QueryDiffTask(params=arguments)
227
+
228
+ # Execute task
229
+ result = await asyncio.get_event_loop().run_in_executor(None, task.execute)
230
+
231
+ # Convert to dict if it's a model
232
+ if hasattr(result, "model_dump"):
233
+ return result.model_dump(mode="json")
234
+ return result
235
+ except Exception:
236
+ logger.exception("Error executing query diff")
237
+ raise
238
+
239
+ async def _profile_diff(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
240
+ """Execute profile diff task"""
241
+ try:
242
+ task = ProfileDiffTask(params=arguments)
243
+
244
+ # Execute task
245
+ result = await asyncio.get_event_loop().run_in_executor(None, task.execute)
246
+
247
+ # Convert to dict if it's a model
248
+ if hasattr(result, "model_dump"):
249
+ return result.model_dump(mode="json")
250
+ return result
251
+ except Exception:
252
+ logger.exception("Error executing profile diff")
253
+ raise
254
+
255
+ async def run(self):
256
+ """Run the MCP server"""
257
+ async with stdio_server() as (read_stream, write_stream):
258
+ await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
259
+
260
+
261
+ async def run_mcp_server(**kwargs):
262
+ """
263
+ Entry point for running the MCP server
264
+
265
+ Args:
266
+ **kwargs: Arguments for loading RecceContext (dbt options, etc.)
267
+ """
268
+ # Setup logging
269
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
270
+
271
+ # Load Recce context
272
+ context = load_context(**kwargs)
273
+
274
+ # Create and run server
275
+ server = RecceMCPServer(context)
276
+ await server.run()
recce/state/const.py CHANGED
@@ -21,6 +21,6 @@ RECCE_CLOUD_PASSWORD_MISSING = ErrorMessage(
21
21
  )
22
22
 
23
23
  RECCE_API_TOKEN_MISSING = ErrorMessage(
24
- error_message="No Recc API token is provided",
24
+ error_message="No Recce API token is provided",
25
25
  hint_message="Please login to Recce Cloud and copy the API token from the settings page",
26
26
  )
@@ -8,9 +8,7 @@ from recce.exceptions import RecceException
8
8
  from recce.pull_request import fetch_pr_metadata
9
9
 
10
10
  from ..util.io import SupportedFileTypes, file_io_factory
11
- from .const import (
12
- RECCE_CLOUD_TOKEN_MISSING,
13
- )
11
+ from .const import RECCE_API_TOKEN_MISSING
14
12
  from .state import RecceState
15
13
 
16
14
  logger = logging.getLogger("uvicorn")
@@ -55,7 +53,7 @@ class RecceStateLoader(ABC):
55
53
  self.catalog = "preview"
56
54
  self.share_id = self.cloud_options.get("share_id")
57
55
  else:
58
- raise RecceException(RECCE_CLOUD_TOKEN_MISSING.error_message)
56
+ raise RecceException(RECCE_API_TOKEN_MISSING.error_message)
59
57
 
60
58
  @property
61
59
  def token(self):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: recce-nightly
3
- Version: 1.23.0.20251029
3
+ Version: 1.23.0.20251030
4
4
  Summary: Environment diff tool for dbt
5
5
  Home-page: https://github.com/InfuseAI/recce
6
6
  Author: InfuseAI Dev Team
@@ -39,8 +39,11 @@ Requires-Dist: python-multipart
39
39
  Requires-Dist: GitPython
40
40
  Requires-Dist: PyGithub
41
41
  Requires-Dist: sqlglot
42
+ Provides-Extra: mcp
43
+ Requires-Dist: mcp>=1.0.0; extra == "mcp"
42
44
  Provides-Extra: dev
43
45
  Requires-Dist: pytest>=4.6; extra == "dev"
46
+ Requires-Dist: pytest-asyncio; extra == "dev"
44
47
  Requires-Dist: pytest-flake8; extra == "dev"
45
48
  Requires-Dist: black>=25.1.0; extra == "dev"
46
49
  Requires-Dist: isort>=6.0.1; extra == "dev"
@@ -1,7 +1,7 @@
1
- recce/VERSION,sha256=c46n2tbAF9VpZ7z1Mky2wXxGSSi6GfZ3DJFMd7rA_qY,16
1
+ recce/VERSION,sha256=1ELJHFjMvjvnFJJ00qb31P6k7uqIchH_h5H_PW25o30,16
2
2
  recce/__init__.py,sha256=gT5G7sJ78C1GHAWr4pna6UvVNxPKs29_EgFS_ia3DT4,2554
3
3
  recce/artifact.py,sha256=BdNW5eQQqEKu5cRzA_XusKMIN73V7-MTQP0VxnLdd9E,9265
4
- recce/cli.py,sha256=ws50nwIzWvYcLNPDfcI6N69k5DaaIONKxloqa-5UdE8,52989
4
+ recce/cli.py,sha256=McDz__wg_whFspjlsD_m9T4268eVQ5VrOJe7FEip7ag,56427
5
5
  recce/config.py,sha256=noWimlOdGHAtBhNoApK5gRTQMwNPmUGAmZu39MV7szo,4697
6
6
  recce/connect_to_cloud.py,sha256=CT7ELcVCTNa9ukSp_gMrJ0Av2UCUpF9H03tIk9y7U-4,4768
7
7
  recce/core.py,sha256=Fph2kodFqzXy3cob80M9-nPVcv-Xxzme-E-bKFJ4cBQ,10754
@@ -9,6 +9,7 @@ recce/diff.py,sha256=L2_bzQ3__PO-0aeir8PHF8FvSOUmQ8WcDXgML1-mHdY,748
9
9
  recce/exceptions.py,sha256=SclQ678GrHGjw7p_ZFJ3vZaL_yMU5xABeIAm2u_W2bk,592
10
10
  recce/git.py,sha256=8Eg-6NzL-KjA3rT-ibbAyaCwGlzV0JqH3yGikrJNMDA,2344
11
11
  recce/github.py,sha256=vIwHTpB8YWEUcHQlW0nToUUKmGGO0NcmB_Q5RvxQLLE,7438
12
+ recce/mcp_server.py,sha256=NjR1q4pMzph0Ps1pQlFX2Go83ml1JQD4ilP_4mLH7QI,11461
12
13
  recce/pull_request.py,sha256=dLHsdiMtL4NC6Vkwp8Mq4baJmnqbm19lfiDti9MREN0,3850
13
14
  recce/run.py,sha256=ctp-K2FmrsGrS4xEZKDMbMtitNw2QKJ6Tq_0OvCWaaM,14080
14
15
  recce/server.py,sha256=FURnuOtooA4ezglEouV_-J-y2ECrNjd6vvSiROnm7Nc,22851
@@ -23,11 +24,13 @@ recce/apis/check_api.py,sha256=KMCXSMl1qqzx2jQgRqCrD4j_cY3EHBbM3H2-t-6saAU,6227
23
24
  recce/apis/check_func.py,sha256=gktbCcyk3WGvWRJJ-wDnwv7NrIny2nTHWLl1-kdiVRo,4183
24
25
  recce/apis/run_api.py,sha256=eOaxOxXDkH59uqGCd4blld7edavUx7JU_DCd2WAYrL8,3416
25
26
  recce/apis/run_func.py,sha256=6wC8TDU-h7TLr2VZH7HNsWaUVlQ9HBN5N_dwqfi4lMY,7440
26
- recce/data/404.html,sha256=Uf2QEM3obZQFhZi7F7OYjmPlXbQTBVsn8D9L_srTeJw,59085
27
+ recce/data/404.html,sha256=WyRvwUWhYVYBrARjspe_zhkppuNIXnzDJQNEPpDdXkM,59085
27
28
  recce/data/auth_callback.html,sha256=H-XfdlAFiw5VU2RpKBVQbwh1AIqJrPHrFA0S09nNJZA,94779
28
29
  recce/data/favicon.ico,sha256=B2mBumUOnzvUrXrqNkrc5QfdDXjzEXRcWkWur0fJ6sM,2565
29
- recce/data/index.html,sha256=wAMuaWDbPpy7XFe8iI5mp6_evy9Qx6ugqhuo5aQi_AM,77170
30
- recce/data/index.txt,sha256=8RLgnQrk6dzlyqYqg-Cu4X3giA3z6c_llJbnrma-soU,6406
30
+ recce/data/index.html,sha256=9ZzdgS_axunI4Wo-Lhp37ua70TaaYKnZ2EPDIK05tmE,77170
31
+ recce/data/index.txt,sha256=FO8bYezNOfzIF7ANp8bT0POjJwDr0Lk3TPYz5nqQlnQ,6406
32
+ recce/data/_next/static/9_NHXNrry9XjEuvMyJcMg/_buildManifest.js,sha256=oqgeL8q3W6T625zlDuGLFDWY3H4_3tlJCNWvj42kYcs,544
33
+ recce/data/_next/static/9_NHXNrry9XjEuvMyJcMg/_ssgManifest.js,sha256=Z49s4suAsf5y_GfnQSvm4qtq2ggxEbZPfEDTXjy6XgA,80
31
34
  recce/data/_next/static/chunks/12f8fac4-a2fd98f42126999f.js,sha256=ILGeY9xCblLTFgnG9zdwGqe5jxxulBBz761KM3HRxMc,152792
32
35
  recce/data/_next/static/chunks/272-058426d7d18ce061.js,sha256=-MHfq8GlLLjSveELfok_YIHlMtuBkYY-hCsJc-EyQOA,10714
33
36
  recce/data/_next/static/chunks/3a92ee20-9c4519f55ecaa314.js,sha256=3pmoD3iJD1ZSIoY-v_9ZsGqXu-ILHdy5FiKF3vvaN2s,177908
@@ -68,8 +71,6 @@ recce/data/_next/static/css/5da8812af0008c76.css,sha256=1JLGRCtK9HwSjFvlp-iimWYY
68
71
  recce/data/_next/static/css/8edca58d4abcf908.css,sha256=Ab-wk7RhsXgiX5fkJsrCJ-EVvrS1hZtOFpI0uZQSgL0,8093
69
72
  recce/data/_next/static/css/abdb9814a3dd18bb.css,sha256=Do5x9FJaOIe-pSBlbk_PP7PQAYFbDXTTl_0PWY4cnBM,1327
70
73
  recce/data/_next/static/css/af2813f12c8a4fd8.css,sha256=sLhHpBJpH5zsmd5h13LU922e3dTlJWkPzZ5R67f8CzM,12260
71
- recce/data/_next/static/fCULljmCJ7BP8dZt7mK5Y/_buildManifest.js,sha256=oqgeL8q3W6T625zlDuGLFDWY3H4_3tlJCNWvj42kYcs,544
72
- recce/data/_next/static/fCULljmCJ7BP8dZt7mK5Y/_ssgManifest.js,sha256=Z49s4suAsf5y_GfnQSvm4qtq2ggxEbZPfEDTXjy6XgA,80
73
74
  recce/data/_next/static/media/montserrat-cyrillic-800-normal.728ecd06.woff,sha256=XiXifLCW_CfSR4eNCIxDzi3Cg59f6L34ARTHrw-hoDA,10924
74
75
  recce/data/_next/static/media/montserrat-cyrillic-800-normal.ffc85bb1.woff2,sha256=6XlujeGVTucujV3iPSa7j1d9S5A6uuH-EduJBCeVFu8,11180
75
76
  recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.622429c0.woff2,sha256=9hQT0vu2HmOb21Pk5Ht7bydZU7QIsY7rCyAPtm1VxM8,12144
@@ -96,10 +97,10 @@ recce/models/run.py,sha256=QK2gvOWvko9YYhd2NLs3BPt5l4MSCZGwpzTAiqx9zJw,1161
96
97
  recce/models/types.py,sha256=9ReOyIv3rUD3_cIQfB9Rplb0L2YQuZr4kS9Zai30nB8,5234
97
98
  recce/state/__init__.py,sha256=V1FRPTQJUz-uwI3Cn8wDa5Z9bueVs86MR_1Ti4RGfPc,650
98
99
  recce/state/cloud.py,sha256=hCLvRgMwQwRxlcQhU33L8gXq6yoytBdAIwgWIWPTeLc,25489
99
- recce/state/const.py,sha256=Me3uVQHi_uZysl3tfpmghPInuKF236X6LfMKVvJlCgA,827
100
+ recce/state/const.py,sha256=5XwGWEv4RhGucgl1RHWg_67gVY7fV0-jsbZPpfnOCTs,828
100
101
  recce/state/local.py,sha256=bZIkl7cAfyYaGgYEr3uD_JLrtwlHBnu8_o1Qz2djQzw,1920
101
102
  recce/state/state.py,sha256=D3wBd8VLtrYEqAb0ueNxzjF84OB4vFDNJL3a1fPhziQ,4177
102
- recce/state/state_loader.py,sha256=_4vzZ3XiIMdC7G2m2WUhGi_IIoJLzsE7KwTVUeebMc0,5979
103
+ recce/state/state_loader.py,sha256=IiGaFDm2y_0NMsv_YUZhSuDTsvWmdMrf9SXIv9w5sKE,5966
103
104
  recce/tasks/__init__.py,sha256=b553AtDHjYROgmMePv_Hv_X3fjh4iEn11gzzpUJz6_o,610
104
105
  recce/tasks/core.py,sha256=JFYa1CfgOiRPQ7KVTwMuxJjhMB-pvCwB-xezVt-h3RU,4080
105
106
  recce/tasks/dataframe.py,sha256=03UBWwt0DFTXlaEOtnV5i_mxdRKD7UbRayewEL2Ub48,3650
@@ -125,14 +126,16 @@ recce/util/pydantic_model.py,sha256=KumKuyCjbTzEMsKLE4-b-eZfp0gLhYDdmVtw1-hxiJw,
125
126
  recce/util/recce_cloud.py,sha256=fM4At7-90dS_TXIAxYNjWdEOW1OUbjTOqYmwWt89eEw,17171
126
127
  recce/util/singleton.py,sha256=1cU99I0f9tjuMQLMJyLsK1oK3fZJMsO5-TbRHAMXqds,627
127
128
  recce/yaml/__init__.py,sha256=PAym5akbtL24Ag7VR7EW8SS2VINNaJU06esbfe-ek-U,1328
128
- recce_nightly-1.23.0.20251029.dist-info/licenses/LICENSE,sha256=CQjjMy9aYPhfe8xG_bcpIfKtNkdxLZ5IOb8oPygtUhY,11343
129
+ recce_nightly-1.23.0.20251030.dist-info/licenses/LICENSE,sha256=CQjjMy9aYPhfe8xG_bcpIfKtNkdxLZ5IOb8oPygtUhY,11343
129
130
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
130
131
  tests/test_cli.py,sha256=CFtP4eJrtcHwpzoEgJAyveQcqxAmDQuWXPrLc_Iny7Q,6626
132
+ tests/test_cli_mcp_optional.py,sha256=w6Iwr22fheaOxw6vGXVZyvi0pw5uh8jDJKcdnd64-_o,1629
131
133
  tests/test_cloud_listing_cli.py,sha256=77As9n0Sngdh53dfHkShmqDETMTOohlqw1xXO9cIago,14208
132
134
  tests/test_config.py,sha256=ODDFe_XF6gphmSmmc422dGLBaCCmG-IjDzTkD5SJsJE,1557
133
135
  tests/test_connect_to_cloud.py,sha256=b2fgV8L1iQBdEwh6RumMsIIyYg7GtMOMRz1dvE3WRPg,3059
134
136
  tests/test_core.py,sha256=WgCFm8Au3YQI-V5UbZ6LA8irMNHRc7NWutIq2Mny0LE,6242
135
137
  tests/test_dbt.py,sha256=VzXvdoJNwwEmKNhJJDNB1N_qZYwp1aoJQ1sLcoyRBmk,1316
138
+ tests/test_mcp_server.py,sha256=k7FxIgHNQF-DWrFJ6eLVi3uyhlDz3AO5VHCEwY56v1s,7709
136
139
  tests/test_pull_request.py,sha256=HmZo5MoDaoKSgPwbLxJ3Ur3ajZ7IxhkzJxaOmhg6bwE,3562
137
140
  tests/test_server.py,sha256=sq03awzG-kvLNJwHaZGDvVjd2uAs502XUKZ2nLNwvRQ,3391
138
141
  tests/test_summary.py,sha256=D0WvAkdO-vzGcvholH2rfS1wTxUXjVHwWm59fWy45eA,2876
@@ -154,8 +157,8 @@ tests/tasks/test_row_count.py,sha256=21PaP2aq-x8-pqwzWHRT1sixhQ8g3CQNRWOZTTmbK0s
154
157
  tests/tasks/test_schema.py,sha256=7ds4Vx8ixaiIWDR49Lvjem4xlPkRP1cXazDRY3roUak,3121
155
158
  tests/tasks/test_top_k.py,sha256=YR_GS__DJsbDlQVaEEdJvNQ3fh1VmV5Nb3G7lb0r6YM,1779
156
159
  tests/tasks/test_valuediff.py,sha256=_xQJGgxsXoy2NYk_Z6Hsw2FlVh6zk2nN_iUueyRN1e8,2046
157
- recce_nightly-1.23.0.20251029.dist-info/METADATA,sha256=Ff3-DNP7eKwIViok1VaavWqYid0PiCqvvbEL1p4P9v0,9471
158
- recce_nightly-1.23.0.20251029.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
159
- recce_nightly-1.23.0.20251029.dist-info/entry_points.txt,sha256=oqoY_IiwIqXbgrIsPnlqUqao2eiIeP2dprowkOlmeyg,40
160
- recce_nightly-1.23.0.20251029.dist-info/top_level.txt,sha256=6PKGVpf75idP0C6KEaldDzzZUauIxNu1ZDstau1pI4I,12
161
- recce_nightly-1.23.0.20251029.dist-info/RECORD,,
160
+ recce_nightly-1.23.0.20251030.dist-info/METADATA,sha256=pW5TlLbxBDBFfVk45aOeu54t21K9X866P9bnXJ10hmY,9579
161
+ recce_nightly-1.23.0.20251030.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
162
+ recce_nightly-1.23.0.20251030.dist-info/entry_points.txt,sha256=oqoY_IiwIqXbgrIsPnlqUqao2eiIeP2dprowkOlmeyg,40
163
+ recce_nightly-1.23.0.20251030.dist-info/top_level.txt,sha256=6PKGVpf75idP0C6KEaldDzzZUauIxNu1ZDstau1pI4I,12
164
+ recce_nightly-1.23.0.20251030.dist-info/RECORD,,
@@ -0,0 +1,45 @@
1
+ """
2
+ Test that CLI can be imported even when mcp is not available.
3
+ """
4
+
5
+ import sys
6
+ from unittest.mock import patch
7
+
8
+
9
+ def test_cli_can_be_imported_without_mcp():
10
+ """Test that recce.cli can be imported even if mcp package is not available"""
11
+ # This test verifies that the CLI module doesn't fail to import
12
+ # when mcp is not installed, since mcp is an optional dependency
13
+ from recce import cli
14
+
15
+ assert cli is not None
16
+ assert hasattr(cli, "cli")
17
+ assert hasattr(cli, "mcp_server")
18
+
19
+
20
+ def test_mcp_server_command_fails_gracefully_without_mcp():
21
+ """Test that mcp-server command shows helpful error when mcp is not available"""
22
+ # Mock sys.modules to simulate mcp not being installed
23
+ with patch.dict(sys.modules, {"mcp": None, "mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}):
24
+ # Remove mcp_server from modules to force reimport
25
+ if "recce.mcp_server" in sys.modules:
26
+ del sys.modules["recce.mcp_server"]
27
+
28
+ from recce.cli import mcp_server
29
+
30
+ # The function should exist
31
+ assert mcp_server is not None
32
+
33
+ # When called, it should handle ImportError gracefully
34
+ # (We can't easily test the actual execution without more mocking,
35
+ # but we've verified the function exists and can be imported)
36
+
37
+
38
+ def test_cli_command_exists():
39
+ """Test that both server and mcp-server commands are registered"""
40
+ from recce.cli import cli
41
+
42
+ # Check that both commands exist
43
+ commands = {cmd.name for cmd in cli.commands.values()}
44
+ assert "server" in commands
45
+ assert "mcp_server" in commands or "mcp-server" in commands
@@ -0,0 +1,208 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ # Skip all tests in this module if mcp is not available
6
+ pytest.importorskip("mcp")
7
+
8
+ from recce.core import RecceContext # noqa: E402
9
+ from recce.mcp_server import RecceMCPServer, run_mcp_server # noqa: E402
10
+ from recce.models.types import LineageDiff # noqa: E402
11
+ from recce.tasks.profile import ProfileDiffTask # noqa: E402
12
+ from recce.tasks.query import QueryDiffTask, QueryTask # noqa: E402
13
+ from recce.tasks.rowcount import RowCountDiffTask # noqa: E402
14
+
15
+
16
+ @pytest.fixture
17
+ def mcp_server():
18
+ """Fixture to create a RecceMCPServer instance for testing"""
19
+ mock_context = MagicMock(spec=RecceContext)
20
+ return RecceMCPServer(mock_context), mock_context
21
+
22
+
23
+ class TestRecceMCPServer:
24
+ """Test cases for the RecceMCPServer class"""
25
+
26
+ def test_server_initialization(self, mcp_server):
27
+ """Test that the MCP server initializes correctly"""
28
+ server, mock_context = mcp_server
29
+ assert server.context == mock_context
30
+ assert server.server is not None
31
+ assert server.server.name == "recce"
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_get_lineage_diff(self, mcp_server):
35
+ """Test the get_lineage_diff tool"""
36
+ server, mock_context = mcp_server
37
+ # Mock the lineage diff response
38
+ mock_lineage_diff = MagicMock(spec=LineageDiff)
39
+ mock_lineage_diff.model_dump.return_value = {
40
+ "added": ["model.project.new_model"],
41
+ "removed": ["model.project.old_model"],
42
+ "modified": ["model.project.changed_model"],
43
+ }
44
+ mock_context.get_lineage_diff.return_value = mock_lineage_diff
45
+
46
+ # Execute the method
47
+ result = await server._get_lineage_diff({})
48
+
49
+ # Verify the result
50
+ assert "added" in result
51
+ assert "removed" in result
52
+ assert "modified" in result
53
+ mock_context.get_lineage_diff.assert_called_once()
54
+ mock_lineage_diff.model_dump.assert_called_once_with(mode="json")
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_row_count_diff(self, mcp_server):
58
+ """Test the row_count_diff tool"""
59
+ server, _ = mcp_server
60
+ # Mock the task execution
61
+ mock_result = {"results": [{"node_id": "model.project.my_model", "base": 100, "current": 105, "diff": 5}]}
62
+
63
+ with patch.object(RowCountDiffTask, "execute", return_value=mock_result):
64
+ result = await server._row_count_diff({"node_names": ["my_model"]})
65
+
66
+ # Verify the result
67
+ assert result == mock_result
68
+ assert "results" in result
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_query(self, mcp_server):
72
+ """Test the query tool"""
73
+ server, _ = mcp_server
74
+ # Mock the task execution
75
+ mock_result = MagicMock()
76
+ mock_result.model_dump.return_value = {
77
+ "columns": ["id", "name"],
78
+ "data": [[1, "Alice"], [2, "Bob"]],
79
+ }
80
+
81
+ with patch.object(QueryTask, "execute", return_value=mock_result):
82
+ result = await server._query({"sql_template": "SELECT * FROM {{ ref('my_model') }}", "base": False})
83
+
84
+ # Verify the result
85
+ assert "columns" in result
86
+ assert "data" in result
87
+ mock_result.model_dump.assert_called_once_with(mode="json")
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_query_with_base_flag(self, mcp_server):
91
+ """Test the query tool with base environment flag"""
92
+ server, _ = mcp_server
93
+ mock_result = {"columns": ["id"], "data": [[1]]}
94
+
95
+ with patch.object(QueryTask, "execute", return_value=mock_result) as mock_execute:
96
+ with patch.object(QueryTask, "__init__", return_value=None):
97
+ task = QueryTask(params={"sql_template": "SELECT 1"})
98
+ task.is_base = True
99
+ task.execute = mock_execute
100
+
101
+ result = await server._query({"sql_template": "SELECT 1", "base": True})
102
+
103
+ # Verify base flag was set (would need to inspect task creation)
104
+ assert result == mock_result
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_query_diff(self, mcp_server):
108
+ """Test the query_diff tool"""
109
+ server, _ = mcp_server
110
+ # Mock the task execution
111
+ mock_result = MagicMock()
112
+ mock_result.model_dump.return_value = {
113
+ "diff": {
114
+ "added": [[3, "Charlie"]],
115
+ "removed": [[1, "Alice"]],
116
+ "modified": [],
117
+ }
118
+ }
119
+
120
+ with patch.object(QueryDiffTask, "execute", return_value=mock_result):
121
+ result = await server._query_diff(
122
+ {
123
+ "sql_template": "SELECT * FROM {{ ref('my_model') }}",
124
+ "primary_keys": ["id"],
125
+ }
126
+ )
127
+
128
+ # Verify the result
129
+ assert "diff" in result
130
+ mock_result.model_dump.assert_called_once_with(mode="json")
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_profile_diff(self, mcp_server):
134
+ """Test the profile_diff tool"""
135
+ server, _ = mcp_server
136
+ # Mock the task execution
137
+ mock_result = MagicMock()
138
+ mock_result.model_dump.return_value = {
139
+ "columns": {
140
+ "id": {
141
+ "base": {"min": 1, "max": 100, "avg": 50.5},
142
+ "current": {"min": 1, "max": 105, "avg": 53.0},
143
+ }
144
+ }
145
+ }
146
+
147
+ with patch.object(ProfileDiffTask, "execute", return_value=mock_result):
148
+ result = await server._profile_diff({"model": "my_model", "columns": ["id"]})
149
+
150
+ # Verify the result
151
+ assert "columns" in result
152
+ mock_result.model_dump.assert_called_once_with(mode="json")
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_error_handling(self, mcp_server):
156
+ """Test error handling in tool execution"""
157
+ server, mock_context = mcp_server
158
+ # Make get_lineage_diff raise an exception
159
+ mock_context.get_lineage_diff.side_effect = Exception("Test error")
160
+
161
+ # The method should raise the exception
162
+ with pytest.raises(Exception, match="Test error"):
163
+ await server._get_lineage_diff({})
164
+
165
+
166
+ class TestRunMCPServer:
167
+ """Test cases for the run_mcp_server function"""
168
+
169
+ @pytest.mark.asyncio
170
+ @patch("recce.mcp_server.load_context")
171
+ @patch.object(RecceMCPServer, "run")
172
+ async def test_run_mcp_server(self, mock_run, mock_load_context):
173
+ """Test the run_mcp_server entry point"""
174
+ # Mock the context
175
+ mock_context = MagicMock(spec=RecceContext)
176
+ mock_load_context.return_value = mock_context
177
+
178
+ # Mock the server run method
179
+ mock_run.return_value = None
180
+
181
+ # Run the server
182
+ await run_mcp_server(project_dir="/test/path")
183
+
184
+ # Verify context was loaded with correct kwargs
185
+ mock_load_context.assert_called_once_with(project_dir="/test/path")
186
+
187
+ # Verify server was run
188
+ mock_run.assert_called_once()
189
+
190
+ @pytest.mark.asyncio
191
+ @patch("recce.mcp_server.load_context")
192
+ async def test_run_mcp_server_context_error(self, mock_load_context):
193
+ """Test run_mcp_server handles context loading errors"""
194
+ # Make load_context raise an exception
195
+ mock_load_context.side_effect = FileNotFoundError("manifest.json not found")
196
+
197
+ # The function should raise the exception
198
+ with pytest.raises(FileNotFoundError):
199
+ await run_mcp_server()
200
+
201
+
202
+ def test_mcp_cli_command_exists():
203
+ """Test that the mcp-server CLI command is registered"""
204
+ from recce.cli import cli
205
+
206
+ # Check that mcp_server is in the CLI commands
207
+ commands = [cmd.name for cmd in cli.commands.values()]
208
+ assert "mcp_server" in commands or "mcp-server" in commands