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/VERSION +1 -1
- recce/cli.py +146 -35
- recce/data/404.html +1 -1
- recce/data/index.html +1 -1
- recce/data/index.txt +1 -1
- recce/mcp_server.py +276 -0
- recce/state/const.py +1 -1
- recce/state/state_loader.py +2 -4
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/METADATA +4 -1
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/RECORD +18 -15
- tests/test_cli_mcp_optional.py +45 -0
- tests/test_mcp_server.py +208 -0
- /recce/data/_next/static/{fCULljmCJ7BP8dZt7mK5Y → 9_NHXNrry9XjEuvMyJcMg}/_buildManifest.js +0 -0
- /recce/data/_next/static/{fCULljmCJ7BP8dZt7mK5Y → 9_NHXNrry9XjEuvMyJcMg}/_ssgManifest.js +0 -0
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/WHEEL +0 -0
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/entry_points.txt +0 -0
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/licenses/LICENSE +0 -0
- {recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/top_level.txt +0 -0
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
|
|
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
|
)
|
recce/state/state_loader.py
CHANGED
|
@@ -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(
|
|
56
|
+
raise RecceException(RECCE_API_TOKEN_MISSING.error_message)
|
|
59
57
|
|
|
60
58
|
@property
|
|
61
59
|
def token(self):
|
{recce_nightly-1.23.0.20251029.dist-info → recce_nightly-1.23.0.20251030.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: recce-nightly
|
|
3
|
-
Version: 1.23.0.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
30
|
-
recce/data/index.txt,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
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.
|
|
158
|
-
recce_nightly-1.23.0.
|
|
159
|
-
recce_nightly-1.23.0.
|
|
160
|
-
recce_nightly-1.23.0.
|
|
161
|
-
recce_nightly-1.23.0.
|
|
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
|
tests/test_mcp_server.py
ADDED
|
@@ -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
|
|
File without changes
|
|
File without changes
|