@malloy-publisher/server 0.0.76 → 0.0.77
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.
- package/dist/server.js +4319 -185
- package/dxt/malloy_bridge.py +274 -0
- package/dxt/manifest.json +22 -0
- package/malloy_mcp.dxt +0 -0
- package/package.json +2 -1
- package/src/mcp/server.ts +3 -0
- package/src/mcp/tools/discovery_tools.ts +258 -0
- package/src/mcp/tools/execute_query_tool.ts +14 -10
- package/src/service/model.ts +11 -0
- package/src/service/package.spec.ts +3 -0
- package/src/service/package.ts +12 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import signal
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
# Set up logging with more detailed format
|
|
12
|
+
logging.basicConfig(
|
|
13
|
+
filename='/tmp/malloy_bridge.log',
|
|
14
|
+
level=logging.DEBUG,
|
|
15
|
+
format='%(asctime)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class ImprovedMalloyMCPBridge:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.mcp_url = "http://localhost:4040/mcp"
|
|
21
|
+
|
|
22
|
+
# Set stdin/stdout to line buffered mode for better responsiveness
|
|
23
|
+
sys.stdin.reconfigure(line_buffering=True)
|
|
24
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
25
|
+
|
|
26
|
+
# Set up signal handlers for graceful shutdown
|
|
27
|
+
signal.signal(signal.SIGTERM, self.signal_handler)
|
|
28
|
+
signal.signal(signal.SIGINT, self.signal_handler)
|
|
29
|
+
|
|
30
|
+
logging.info("Bridge initialized with improved stdio handling")
|
|
31
|
+
|
|
32
|
+
def signal_handler(self, signum, frame):
|
|
33
|
+
logging.info(f"Received signal {signum}, shutting down gracefully")
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
def parse_sse_response(self, response_text: str, request_id: Optional[Any] = None) -> Dict[str, Any]:
|
|
37
|
+
"""Parse Server-Sent Events response format with improved error handling"""
|
|
38
|
+
lines = response_text.strip().split('\n')
|
|
39
|
+
data_line = None
|
|
40
|
+
|
|
41
|
+
# Look for data line in SSE format
|
|
42
|
+
for line in lines:
|
|
43
|
+
if line.startswith('data: '):
|
|
44
|
+
data_line = line[6:] # Remove 'data: ' prefix
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
# If no SSE format, try parsing the entire response as JSON
|
|
48
|
+
if not data_line:
|
|
49
|
+
data_line = response_text.strip()
|
|
50
|
+
|
|
51
|
+
if data_line:
|
|
52
|
+
try:
|
|
53
|
+
parsed_data = json.loads(data_line)
|
|
54
|
+
# Ensure the response has a proper ID
|
|
55
|
+
if 'id' not in parsed_data or parsed_data['id'] is None:
|
|
56
|
+
parsed_data['id'] = request_id
|
|
57
|
+
|
|
58
|
+
logging.debug(f"Successfully parsed response for request {request_id}")
|
|
59
|
+
return parsed_data
|
|
60
|
+
|
|
61
|
+
except json.JSONDecodeError as e:
|
|
62
|
+
logging.error(f"JSON decode error for request {request_id}: {e}")
|
|
63
|
+
return {
|
|
64
|
+
"jsonrpc": "2.0",
|
|
65
|
+
"error": {
|
|
66
|
+
"code": -32603,
|
|
67
|
+
"message": f"Failed to parse response: {str(e)}"
|
|
68
|
+
},
|
|
69
|
+
"id": request_id
|
|
70
|
+
}
|
|
71
|
+
else:
|
|
72
|
+
logging.error(f"No data found in response for request {request_id}")
|
|
73
|
+
return {
|
|
74
|
+
"jsonrpc": "2.0",
|
|
75
|
+
"error": {
|
|
76
|
+
"code": -32603,
|
|
77
|
+
"message": "No data found in response"
|
|
78
|
+
},
|
|
79
|
+
"id": request_id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def send_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
"""Send a JSON-RPC request to the Malloy MCP endpoint with improved timeout handling"""
|
|
84
|
+
request_id = request.get("id")
|
|
85
|
+
method = request.get("method", "unknown")
|
|
86
|
+
start_time = time.time()
|
|
87
|
+
|
|
88
|
+
logging.debug(f"Sending request {request_id} ({method}) to Malloy server")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Ensure the request has proper structure
|
|
92
|
+
if "jsonrpc" not in request:
|
|
93
|
+
request["jsonrpc"] = "2.0"
|
|
94
|
+
|
|
95
|
+
# Prepare the request with shorter timeout for responsiveness
|
|
96
|
+
data = json.dumps(request).encode('utf-8')
|
|
97
|
+
req = urllib.request.Request(
|
|
98
|
+
self.mcp_url,
|
|
99
|
+
data=data,
|
|
100
|
+
headers={
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
'Accept': 'application/json, text/event-stream',
|
|
103
|
+
'Connection': 'close' # Don't keep connections open
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
with urllib.request.urlopen(req, timeout=60) as response:
|
|
108
|
+
response_text = response.read().decode('utf-8')
|
|
109
|
+
parsed_response = self.parse_sse_response(response_text, request_id)
|
|
110
|
+
|
|
111
|
+
elapsed = time.time() - start_time
|
|
112
|
+
logging.debug(f"Request {request_id} completed in {elapsed:.2f}s")
|
|
113
|
+
return parsed_response
|
|
114
|
+
|
|
115
|
+
except urllib.error.HTTPError as e:
|
|
116
|
+
elapsed = time.time() - start_time
|
|
117
|
+
error_message = f"HTTP {e.code}: {e.reason}"
|
|
118
|
+
try:
|
|
119
|
+
error_body = e.read().decode('utf-8')
|
|
120
|
+
error_message += f" - {error_body}"
|
|
121
|
+
except:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
logging.error(f"HTTP error for request {request_id} after {elapsed:.2f}s: {error_message}")
|
|
125
|
+
return {
|
|
126
|
+
"jsonrpc": "2.0",
|
|
127
|
+
"error": {
|
|
128
|
+
"code": e.code,
|
|
129
|
+
"message": error_message
|
|
130
|
+
},
|
|
131
|
+
"id": request_id
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
except urllib.error.URLError as e:
|
|
135
|
+
elapsed = time.time() - start_time
|
|
136
|
+
error_msg = f"Connection error: {str(e)}"
|
|
137
|
+
logging.error(f"URL error for request {request_id} after {elapsed:.2f}s: {error_msg}")
|
|
138
|
+
return {
|
|
139
|
+
"jsonrpc": "2.0",
|
|
140
|
+
"error": {
|
|
141
|
+
"code": -32603,
|
|
142
|
+
"message": error_msg
|
|
143
|
+
},
|
|
144
|
+
"id": request_id
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
elapsed = time.time() - start_time
|
|
149
|
+
error_msg = f"Unexpected error: {str(e)}"
|
|
150
|
+
logging.error(f"Unexpected error for request {request_id} after {elapsed:.2f}s: {error_msg}")
|
|
151
|
+
return {
|
|
152
|
+
"jsonrpc": "2.0",
|
|
153
|
+
"error": {
|
|
154
|
+
"code": -32603,
|
|
155
|
+
"message": error_msg
|
|
156
|
+
},
|
|
157
|
+
"id": request_id
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def safe_print(self, data):
|
|
161
|
+
"""Print with improved error handling and immediate flushing"""
|
|
162
|
+
try:
|
|
163
|
+
output = json.dumps(data)
|
|
164
|
+
print(output)
|
|
165
|
+
sys.stdout.flush() # Force immediate flush
|
|
166
|
+
logging.debug(f"Successfully sent response: {len(output)} chars")
|
|
167
|
+
|
|
168
|
+
except BrokenPipeError:
|
|
169
|
+
logging.error("Broken pipe error - client disconnected")
|
|
170
|
+
sys.exit(0)
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logging.error(f"Print error: {e}")
|
|
174
|
+
# Don't exit on print errors, just log them
|
|
175
|
+
|
|
176
|
+
def process_request(self, line: str) -> None:
|
|
177
|
+
"""Process a single request line with improved error handling"""
|
|
178
|
+
try:
|
|
179
|
+
request = json.loads(line)
|
|
180
|
+
request_id = request.get("id", "unknown")
|
|
181
|
+
method = request.get("method", "unknown")
|
|
182
|
+
|
|
183
|
+
logging.debug(f"Processing request {request_id}: {method}")
|
|
184
|
+
|
|
185
|
+
# Validate required fields
|
|
186
|
+
if not isinstance(request, dict):
|
|
187
|
+
raise ValueError("Request must be a JSON object")
|
|
188
|
+
|
|
189
|
+
if "method" not in request:
|
|
190
|
+
raise ValueError("Request must have a 'method' field")
|
|
191
|
+
|
|
192
|
+
# Ensure ID is present and valid
|
|
193
|
+
if "id" not in request:
|
|
194
|
+
request["id"] = 1 # Default ID
|
|
195
|
+
elif request["id"] is None:
|
|
196
|
+
request["id"] = 1 # Replace null with default
|
|
197
|
+
|
|
198
|
+
# Send request and get response
|
|
199
|
+
response = self.send_request(request)
|
|
200
|
+
|
|
201
|
+
# Send response immediately
|
|
202
|
+
self.safe_print(response)
|
|
203
|
+
|
|
204
|
+
except json.JSONDecodeError as e:
|
|
205
|
+
logging.error(f"JSON parse error: {e}")
|
|
206
|
+
error_response = {
|
|
207
|
+
"jsonrpc": "2.0",
|
|
208
|
+
"error": {
|
|
209
|
+
"code": -32700,
|
|
210
|
+
"message": f"Parse error: {str(e)}"
|
|
211
|
+
},
|
|
212
|
+
"id": None
|
|
213
|
+
}
|
|
214
|
+
self.safe_print(error_response)
|
|
215
|
+
|
|
216
|
+
except ValueError as e:
|
|
217
|
+
logging.error(f"Request validation error: {e}")
|
|
218
|
+
error_response = {
|
|
219
|
+
"jsonrpc": "2.0",
|
|
220
|
+
"error": {
|
|
221
|
+
"code": -32600,
|
|
222
|
+
"message": f"Invalid Request: {str(e)}"
|
|
223
|
+
},
|
|
224
|
+
"id": None
|
|
225
|
+
}
|
|
226
|
+
self.safe_print(error_response)
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logging.error(f"Unexpected error processing request: {e}")
|
|
230
|
+
error_response = {
|
|
231
|
+
"jsonrpc": "2.0",
|
|
232
|
+
"error": {
|
|
233
|
+
"code": -32603,
|
|
234
|
+
"message": f"Internal error: {str(e)}"
|
|
235
|
+
},
|
|
236
|
+
"id": None
|
|
237
|
+
}
|
|
238
|
+
self.safe_print(error_response)
|
|
239
|
+
|
|
240
|
+
def run(self):
|
|
241
|
+
"""Main loop with improved stdin handling"""
|
|
242
|
+
logging.info("Starting main processing loop")
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
# Process stdin line by line with immediate handling
|
|
246
|
+
for line in sys.stdin:
|
|
247
|
+
line = line.strip()
|
|
248
|
+
if not line:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Process each request immediately
|
|
252
|
+
self.process_request(line)
|
|
253
|
+
|
|
254
|
+
except KeyboardInterrupt:
|
|
255
|
+
logging.info("Received keyboard interrupt, shutting down")
|
|
256
|
+
sys.exit(0)
|
|
257
|
+
|
|
258
|
+
except BrokenPipeError:
|
|
259
|
+
logging.info("Broken pipe detected, client disconnected")
|
|
260
|
+
sys.exit(0)
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logging.error(f"Fatal error in main loop: {e}")
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
|
|
266
|
+
logging.info("Main loop completed")
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
try:
|
|
270
|
+
bridge = ImprovedMalloyMCPBridge()
|
|
271
|
+
bridge.run()
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logging.error(f"Failed to start bridge: {e}")
|
|
274
|
+
sys.exit(1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dxt_version": "0.1",
|
|
3
|
+
"name": "Malloy",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Connect to Malloy Publisher, allowing you to write & run Malloy data queries.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Josh Sacks"
|
|
8
|
+
},
|
|
9
|
+
"server": {
|
|
10
|
+
"type": "python",
|
|
11
|
+
"entry_point": "malloy_bridge.py",
|
|
12
|
+
"mcp_config": {
|
|
13
|
+
"command": "python",
|
|
14
|
+
"args": ["${__dirname}/malloy_bridge.py"],
|
|
15
|
+
"env": {
|
|
16
|
+
"PYTHONPATH": "server/lib"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["sql", "malloy", "data", "queries", "charts"],
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|
package/malloy_mcp.dxt
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.77",
|
|
5
5
|
"main": "dist/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.js"
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"uuid": "^11.0.3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
+
"@anthropic-ai/dxt": "^0.1.0",
|
|
53
54
|
"@eslint/compat": "^1.2.7",
|
|
54
55
|
"@eslint/eslintrc": "^3.3.1",
|
|
55
56
|
"@eslint/js": "^9.23.0",
|
package/src/mcp/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { registerQueryResource } from "./resources/query_resource";
|
|
|
9
9
|
import { registerViewResource } from "./resources/view_resource";
|
|
10
10
|
import { registerNotebookResource } from "./resources/notebook_resource";
|
|
11
11
|
import { registerExecuteQueryTool } from "./tools/execute_query_tool";
|
|
12
|
+
import { registerTools } from "./tools/discovery_tools";
|
|
12
13
|
import { registerPromptCapability } from "./prompts/prompt_service.js";
|
|
13
14
|
|
|
14
15
|
export const testServerInfo = {
|
|
@@ -46,6 +47,8 @@ export function initializeMcpServer(projectStore: ProjectStore): McpServer {
|
|
|
46
47
|
console.log("[MCP Init] Registering executeQuery tool...");
|
|
47
48
|
registerExecuteQueryTool(mcpServer, projectStore);
|
|
48
49
|
|
|
50
|
+
registerTools(mcpServer, projectStore);
|
|
51
|
+
|
|
49
52
|
console.log("[MCP Init] Registering prompt capability...");
|
|
50
53
|
registerPromptCapability(mcpServer, projectStore);
|
|
51
54
|
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ProjectStore } from "../../service/project_store";
|
|
3
|
+
import { buildMalloyUri } from "../handler_utils";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
const listPackagesShape = {
|
|
7
|
+
// projectName is required; other fields mirror SDK expectations
|
|
8
|
+
projectName: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe(
|
|
11
|
+
"Project name. Project names are listed in the listProjects tool.",
|
|
12
|
+
),
|
|
13
|
+
};
|
|
14
|
+
type listPackagesParams = z.infer<z.ZodObject<typeof listPackagesShape>>;
|
|
15
|
+
|
|
16
|
+
const getModelsShape = {
|
|
17
|
+
// projectName is required; other fields mirror SDK expectations
|
|
18
|
+
projectName: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe(
|
|
21
|
+
"Project name. Project names are listed in the listProjects tool.",
|
|
22
|
+
),
|
|
23
|
+
packageName: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe(
|
|
26
|
+
"Package name. Package names are listed in the listPackages tool.",
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
type getModelsParams = z.infer<z.ZodObject<typeof getModelsShape>>;
|
|
30
|
+
|
|
31
|
+
const getModelTextShape = {
|
|
32
|
+
projectName: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe(
|
|
35
|
+
"Project name. Project names are listed in the listProjects tool.",
|
|
36
|
+
),
|
|
37
|
+
packageName: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe(
|
|
40
|
+
"Package name. Package names are listed in the listPackages tool.",
|
|
41
|
+
),
|
|
42
|
+
modelPath: z
|
|
43
|
+
.string()
|
|
44
|
+
.describe(
|
|
45
|
+
"Model path. Model paths are listed in the malloy_packageGet tool.",
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
type getModelTextParams = z.infer<z.ZodObject<typeof getModelTextShape>>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Registers the Malloy Project resource type with the MCP server.
|
|
52
|
+
* Handles getting details for the hardcoded 'home' project and listing packages within it.
|
|
53
|
+
*/
|
|
54
|
+
export function registerTools(
|
|
55
|
+
mcpServer: McpServer,
|
|
56
|
+
projectStore: ProjectStore,
|
|
57
|
+
): void {
|
|
58
|
+
mcpServer.tool(
|
|
59
|
+
"malloy_projectList",
|
|
60
|
+
"Lists all Malloy projects",
|
|
61
|
+
{},
|
|
62
|
+
async () => {
|
|
63
|
+
console.log(
|
|
64
|
+
"[MCP LOG] Entering ListResources (project) handler (listing ALL packages)...",
|
|
65
|
+
);
|
|
66
|
+
const allProjects = await Promise.all(
|
|
67
|
+
(await projectStore.listProjects())
|
|
68
|
+
.filter((project) => project.name)
|
|
69
|
+
.map((project) => ({
|
|
70
|
+
name: project.name,
|
|
71
|
+
project: projectStore.getProject(project.name!, false),
|
|
72
|
+
})),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
console.log(`[MCP LOG] Found ${allProjects.length} projects defined.`);
|
|
76
|
+
|
|
77
|
+
const mappedResources = await Promise.all(
|
|
78
|
+
allProjects.map(async (project) => {
|
|
79
|
+
const name = project.name;
|
|
80
|
+
const projectInstance = await project.project;
|
|
81
|
+
const metadata = await projectInstance.getProjectMetadata();
|
|
82
|
+
const readme = metadata.readme;
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
type: "project",
|
|
86
|
+
description: readme || "NO Description available",
|
|
87
|
+
};
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
// console.log(mappedResources);
|
|
91
|
+
console.log(
|
|
92
|
+
`[MCP LOG] ListResources (project): Returning ${mappedResources.length} package resources.`,
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: "resource",
|
|
98
|
+
resource: {
|
|
99
|
+
type: "application/json",
|
|
100
|
+
uri: buildMalloyUri({}, "project"),
|
|
101
|
+
text: JSON.stringify(mappedResources),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
mcpServer.tool(
|
|
110
|
+
"malloy_packageList",
|
|
111
|
+
"Lists all Malloy packages within a project",
|
|
112
|
+
listPackagesShape,
|
|
113
|
+
async (params: listPackagesParams) => {
|
|
114
|
+
const { projectName } = params;
|
|
115
|
+
console.log(
|
|
116
|
+
"[MCP LOG] Entering ListResources (project) handler (listing ALL packages)...",
|
|
117
|
+
);
|
|
118
|
+
const project = await projectStore.getProject(projectName, false);
|
|
119
|
+
const packages = await project.listPackages();
|
|
120
|
+
console.log(`[MCP LOG] Found ${packages.length} packages defined.`);
|
|
121
|
+
const mappedResources = packages.map((pkg) => ({
|
|
122
|
+
modelPath: pkg.name,
|
|
123
|
+
type: "package",
|
|
124
|
+
description: pkg.description,
|
|
125
|
+
}));
|
|
126
|
+
console.log(
|
|
127
|
+
`[MCP LOG] ListResources (project): Returning ${mappedResources.length} package resources.`,
|
|
128
|
+
);
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "resource",
|
|
133
|
+
resource: {
|
|
134
|
+
type: "application/json",
|
|
135
|
+
uri: buildMalloyUri({ project: projectName }, "package"),
|
|
136
|
+
text: JSON.stringify(mappedResources),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
mcpServer.tool(
|
|
145
|
+
"malloy_packageGet",
|
|
146
|
+
"Lists resources within a package",
|
|
147
|
+
getModelsShape,
|
|
148
|
+
async (params: getModelsParams) => {
|
|
149
|
+
const { projectName, packageName } = params;
|
|
150
|
+
console.log(
|
|
151
|
+
"[MCP LOG] Entering GetResources (project) handler (listing ALL packages)...",
|
|
152
|
+
);
|
|
153
|
+
const project = await projectStore.getProject(projectName, false);
|
|
154
|
+
const pkg = await project.getPackage(packageName, false);
|
|
155
|
+
const models = await pkg.listModels();
|
|
156
|
+
console.log(`[MCP LOG] Found ${models.length} models defined.`);
|
|
157
|
+
const mappedResources = models.map((model) => ({
|
|
158
|
+
name: model.path,
|
|
159
|
+
type: "model",
|
|
160
|
+
}));
|
|
161
|
+
console.log(
|
|
162
|
+
`[MCP LOG] ListResources (project): Returning ${mappedResources.length} package resources.`,
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: "resource",
|
|
168
|
+
resource: {
|
|
169
|
+
type: "application/json",
|
|
170
|
+
uri: buildMalloyUri(
|
|
171
|
+
{ project: projectName, package: packageName },
|
|
172
|
+
"model",
|
|
173
|
+
),
|
|
174
|
+
text: JSON.stringify(mappedResources),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
mcpServer.tool(
|
|
183
|
+
"malloy_modelGetText",
|
|
184
|
+
"Gets the raw text content of a model file",
|
|
185
|
+
getModelTextShape,
|
|
186
|
+
async (params: getModelTextParams) => {
|
|
187
|
+
const { projectName, packageName, modelPath } = params;
|
|
188
|
+
console.log(
|
|
189
|
+
`[MCP LOG] Entering GetModelText handler for ${projectName}/${packageName}/${modelPath}...`,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const project = await projectStore.getProject(projectName, false);
|
|
194
|
+
const pkg = await project.getPackage(packageName, false);
|
|
195
|
+
const model = pkg.getModel(modelPath);
|
|
196
|
+
|
|
197
|
+
if (!model) {
|
|
198
|
+
console.log(
|
|
199
|
+
"model not found",
|
|
200
|
+
modelPath,
|
|
201
|
+
"in ",
|
|
202
|
+
pkg.listModels(),
|
|
203
|
+
);
|
|
204
|
+
throw new Error(`Model not found: ${modelPath}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Use the new getModelFileText method
|
|
208
|
+
const fileText = await pkg.getModelFileText(modelPath);
|
|
209
|
+
|
|
210
|
+
console.log(
|
|
211
|
+
`[MCP LOG] Successfully retrieved model text for ${modelPath}`,
|
|
212
|
+
);
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{
|
|
216
|
+
type: "resource",
|
|
217
|
+
resource: {
|
|
218
|
+
type: "text/plain",
|
|
219
|
+
uri: buildMalloyUri(
|
|
220
|
+
{
|
|
221
|
+
project: projectName,
|
|
222
|
+
package: packageName,
|
|
223
|
+
model: modelPath,
|
|
224
|
+
},
|
|
225
|
+
"model-text",
|
|
226
|
+
),
|
|
227
|
+
text: fileText,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error(`[MCP LOG] Error retrieving model text: ${error}`);
|
|
234
|
+
const errorMessage =
|
|
235
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
236
|
+
return {
|
|
237
|
+
content: [
|
|
238
|
+
{
|
|
239
|
+
type: "resource",
|
|
240
|
+
resource: {
|
|
241
|
+
type: "text/plain",
|
|
242
|
+
uri: buildMalloyUri(
|
|
243
|
+
{
|
|
244
|
+
project: projectName,
|
|
245
|
+
package: packageName,
|
|
246
|
+
model: modelPath,
|
|
247
|
+
},
|
|
248
|
+
"model-text",
|
|
249
|
+
),
|
|
250
|
+
text: `Error: ${errorMessage}`,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import {
|
|
3
|
-
McpError,
|
|
4
|
-
ErrorCode,
|
|
5
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
6
3
|
import { z } from "zod";
|
|
7
4
|
import { ProjectStore } from "../../service/project_store";
|
|
8
5
|
import { getModelForQuery } from "../handler_utils";
|
|
@@ -13,16 +10,23 @@ import { buildMalloyUri } from "../handler_utils";
|
|
|
13
10
|
// Zod shape defining required/optional params for executeQuery
|
|
14
11
|
const executeQueryShape = {
|
|
15
12
|
// projectName is required; other fields mirror SDK expectations
|
|
16
|
-
projectName: z
|
|
17
|
-
|
|
13
|
+
projectName: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe(
|
|
16
|
+
"Project name. Project names are listed in the malloy resource list.",
|
|
17
|
+
),
|
|
18
|
+
packageName: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe(
|
|
21
|
+
"Package containing the model. Package names are listed in the malloy resource list.",
|
|
22
|
+
),
|
|
18
23
|
modelPath: z.string().describe("Path to the .malloy model file"),
|
|
19
24
|
query: z.string().optional().describe("Ad-hoc Malloy query code"),
|
|
20
25
|
sourceName: z.string().optional().describe("Source name for a view"),
|
|
21
26
|
queryName: z.string().optional().describe("Named query or view"),
|
|
22
27
|
};
|
|
23
28
|
|
|
24
|
-
//
|
|
25
|
-
type ExecuteQueryParams = z.infer<z.ZodObject<typeof executeQueryShape>>;
|
|
29
|
+
// Type inference is handled automatically by the MCP server based on the executeQueryShape
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Registers the malloy/executeQuery tool with the MCP server.
|
|
@@ -32,11 +36,11 @@ export function registerExecuteQueryTool(
|
|
|
32
36
|
projectStore: ProjectStore,
|
|
33
37
|
): void {
|
|
34
38
|
mcpServer.tool(
|
|
35
|
-
"
|
|
39
|
+
"malloy_executeQuery",
|
|
36
40
|
"Executes a Malloy query (either ad-hoc or a named query/view defined in a model) against the specified model and returns the results as JSON.",
|
|
37
41
|
executeQueryShape,
|
|
38
42
|
/** Handles requests for the malloy/executeQuery tool */
|
|
39
|
-
async (params
|
|
43
|
+
async (params) => {
|
|
40
44
|
// Destructure projectName as well
|
|
41
45
|
const {
|
|
42
46
|
projectName,
|
package/src/service/model.ts
CHANGED
|
@@ -633,4 +633,15 @@ export class Model {
|
|
|
633
633
|
public getModelType(): ModelType {
|
|
634
634
|
return this.modelType;
|
|
635
635
|
}
|
|
636
|
+
|
|
637
|
+
public async getFileText(packagePath: string): Promise<string> {
|
|
638
|
+
const fullPath = path.join(packagePath, this.modelPath);
|
|
639
|
+
try {
|
|
640
|
+
return await fs.readFile(fullPath, "utf8");
|
|
641
|
+
} catch {
|
|
642
|
+
throw new ModelNotFoundError(
|
|
643
|
+
`Model file not found: ${this.modelPath}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
636
647
|
}
|
|
@@ -47,7 +47,9 @@ describe("service/package", () => {
|
|
|
47
47
|
it("should create a package instance", async () => {
|
|
48
48
|
// Using 'as any' for simplified mock Map value in test
|
|
49
49
|
const pkg = new Package(
|
|
50
|
+
"testProject",
|
|
50
51
|
"testPackage",
|
|
52
|
+
testPackageDirectory,
|
|
51
53
|
{ name: "testPackage", description: "Test package" },
|
|
52
54
|
[],
|
|
53
55
|
new Map([
|
|
@@ -133,6 +135,7 @@ describe("service/package", () => {
|
|
|
133
135
|
const packageInstance = new Package(
|
|
134
136
|
"testProject",
|
|
135
137
|
"testPackage",
|
|
138
|
+
testPackageDirectory,
|
|
136
139
|
{ name: "testPackage", description: "Test package" },
|
|
137
140
|
[],
|
|
138
141
|
new Map([
|