@malloy-publisher/server 0.0.76 → 0.0.78

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.
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import json
4
+ import urllib.request
5
+ import urllib.parse
6
+ import urllib.error
7
+ import logging
8
+ import time
9
+ import signal
10
+ from typing import Dict, Any, Optional
11
+
12
+ # Set up logging with more detailed format
13
+ logging.basicConfig(
14
+ filename='/tmp/malloy_bridge.log',
15
+ level=logging.DEBUG,
16
+ format='%(asctime)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
17
+ )
18
+
19
+ class ImprovedMalloyMCPBridge:
20
+ def __init__(self):
21
+ self.mcp_url = "http://localhost:4040/mcp"
22
+ # No tool name mapping needed - server already uses underscore format
23
+
24
+ # Set stdin/stdout to line buffered mode for better responsiveness
25
+ try:
26
+ sys.stdin.reconfigure(line_buffering=True)
27
+ sys.stdout.reconfigure(line_buffering=True)
28
+ except AttributeError:
29
+ # reconfigure not available in older Python versions
30
+ pass
31
+
32
+ # Set up signal handlers for graceful shutdown
33
+ signal.signal(signal.SIGTERM, self.signal_handler)
34
+ signal.signal(signal.SIGINT, self.signal_handler)
35
+ logging.info("Bridge initialized with improved stdio handling")
36
+
37
+ def signal_handler(self, signum, frame):
38
+ logging.info(f"Received signal {signum}, shutting down gracefully")
39
+ sys.exit(0)
40
+
41
+ def parse_sse_response(self, response, request_id: Optional[Any] = None) -> Dict[str, Any]:
42
+ """Parse Server-Sent Events response format with improved error handling"""
43
+ logging.debug(f"Starting SSE parsing for request {request_id}")
44
+
45
+ try:
46
+ # Read response with size limit to prevent memory issues
47
+ max_size = 1024 * 1024 # 1MB limit
48
+ response_text = ""
49
+ bytes_read = 0
50
+
51
+ # Read response in chunks to avoid memory issues
52
+ while True:
53
+ chunk = response.read(8192) # 8KB chunks
54
+ if not chunk:
55
+ break
56
+ bytes_read += len(chunk)
57
+ if bytes_read > max_size:
58
+ logging.warning(f"Response too large ({bytes_read} bytes), truncating")
59
+ break
60
+ response_text += chunk.decode('utf-8')
61
+
62
+ logging.debug(f"Read {bytes_read} bytes from SSE response")
63
+
64
+ lines = response_text.strip().split('\n')
65
+ data_line = None
66
+
67
+ # Look for data line in SSE format
68
+ for line in lines:
69
+ if line.startswith('data: '):
70
+ data_line = line[6:] # Remove 'data: ' prefix
71
+ break
72
+
73
+ # If no SSE format, try parsing the entire response as JSON
74
+ if not data_line:
75
+ data_line = response_text.strip()
76
+
77
+ if data_line:
78
+ try:
79
+ parsed_data = json.loads(data_line)
80
+ # Ensure the response has a proper ID
81
+ if 'id' not in parsed_data or parsed_data['id'] is None:
82
+ parsed_data['id'] = request_id
83
+
84
+ logging.debug(f"Successfully parsed response for request {request_id}")
85
+ return parsed_data
86
+
87
+ except json.JSONDecodeError as e:
88
+ logging.error(f"JSON decode error for request {request_id}: {e}")
89
+ logging.error(f"Raw data that failed to parse: {data_line[:200]}...")
90
+ return {
91
+ "jsonrpc": "2.0",
92
+ "error": {
93
+ "code": -32603,
94
+ "message": f"Failed to parse response: {str(e)}"
95
+ },
96
+ "id": request_id
97
+ }
98
+ else:
99
+ logging.error(f"No data found in response for request {request_id}")
100
+ return {
101
+ "jsonrpc": "2.0",
102
+ "error": {
103
+ "code": -32603,
104
+ "message": "No data found in response"
105
+ },
106
+ "id": request_id
107
+ }
108
+
109
+ except Exception as e:
110
+ logging.error(f"Error reading SSE response for request {request_id}: {e}")
111
+ return {
112
+ "jsonrpc": "2.0",
113
+ "error": {
114
+ "code": -32603,
115
+ "message": f"SSE parsing error: {str(e)}"
116
+ },
117
+ "id": request_id
118
+ }
119
+
120
+ def send_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
121
+ """Send a JSON-RPC request to the Malloy MCP endpoint with improved timeout handling"""
122
+ request_id = request.get("id")
123
+ method = request.get("method", "unknown")
124
+ start_time = time.time()
125
+
126
+ logging.debug(f"Sending request {request_id} ({method}) to Malloy server")
127
+
128
+ # Log ALL method calls for debugging
129
+ logging.info(f"METHOD CALL - Request {request_id}: method='{method}'")
130
+ if method == "tools/call":
131
+ tool_name = request.get("params", {}).get("name", "unknown")
132
+ logging.info(f"TOOL CALL - Request {request_id}: tool_name='{tool_name}'")
133
+ logging.info(f"FULL REQUEST - {json.dumps(request)}")
134
+
135
+ try:
136
+ # Ensure the request has proper structure
137
+ if "jsonrpc" not in request:
138
+ request["jsonrpc"] = "2.0"
139
+
140
+ # Prepare the request with longer timeout for tools/list
141
+ timeout = 10 if method == "tools/list" else 3
142
+ data = json.dumps(request).encode('utf-8')
143
+ req = urllib.request.Request(
144
+ self.mcp_url,
145
+ data=data,
146
+ headers={
147
+ 'Content-Type': 'application/json',
148
+ 'Accept': 'application/json, text/event-stream',
149
+ 'Connection': 'close' # Don't keep connections open
150
+ }
151
+ )
152
+
153
+ logging.debug(f"Using timeout of {timeout}s for method {method}")
154
+
155
+ with urllib.request.urlopen(req, timeout=timeout) as response:
156
+ # Handle SSE response directly from the response object
157
+ content_type = response.headers.get('Content-Type', '')
158
+
159
+ if 'text/event-stream' in content_type:
160
+ logging.debug("Handling SSE response")
161
+ parsed_response = self.parse_sse_response(response, request_id)
162
+ else:
163
+ logging.debug("Handling JSON response")
164
+ response_text = response.read().decode('utf-8')
165
+ try:
166
+ parsed_response = json.loads(response_text)
167
+ if 'id' not in parsed_response or parsed_response['id'] is None:
168
+ parsed_response['id'] = request_id
169
+ except json.JSONDecodeError as e:
170
+ logging.error(f"Failed to parse JSON response: {e}")
171
+ parsed_response = {
172
+ "jsonrpc": "2.0",
173
+ "error": {
174
+ "code": -32603,
175
+ "message": f"Invalid JSON response: {str(e)}"
176
+ },
177
+ "id": request_id
178
+ }
179
+
180
+ # Log server response for debugging
181
+ if "error" in parsed_response:
182
+ logging.error(f"SERVER ERROR - Request {request_id} ({method}): {parsed_response}")
183
+ elif method == "tools/call":
184
+ logging.info(f"TOOL CALL - Server response: {str(parsed_response)[:500]}...")
185
+
186
+ elapsed = time.time() - start_time
187
+ logging.debug(f"Request {request_id} completed in {elapsed:.2f}s")
188
+ return parsed_response
189
+
190
+ except urllib.error.HTTPError as e:
191
+ elapsed = time.time() - start_time
192
+ error_message = f"HTTP {e.code}: {e.reason}"
193
+ try:
194
+ error_body = e.read().decode('utf-8')
195
+ error_message += f" - {error_body}"
196
+ except:
197
+ pass
198
+
199
+ logging.error(f"HTTP error for request {request_id} after {elapsed:.2f}s: {error_message}")
200
+ return {
201
+ "jsonrpc": "2.0",
202
+ "error": {
203
+ "code": e.code,
204
+ "message": error_message
205
+ },
206
+ "id": request_id
207
+ }
208
+
209
+ except urllib.error.URLError as e:
210
+ elapsed = time.time() - start_time
211
+ error_msg = f"Connection error: {str(e)}"
212
+ logging.error(f"URL error for request {request_id} after {elapsed:.2f}s: {error_msg}")
213
+ return {
214
+ "jsonrpc": "2.0",
215
+ "error": {
216
+ "code": -32603,
217
+ "message": error_msg
218
+ },
219
+ "id": request_id
220
+ }
221
+
222
+ except Exception as e:
223
+ elapsed = time.time() - start_time
224
+ error_msg = f"Unexpected error: {str(e)}"
225
+ logging.error(f"Unexpected error for request {request_id} after {elapsed:.2f}s: {error_msg}")
226
+ return {
227
+ "jsonrpc": "2.0",
228
+ "error": {
229
+ "code": -32603,
230
+ "message": error_msg
231
+ },
232
+ "id": request_id
233
+ }
234
+
235
+ def safe_print(self, data):
236
+ """Print with improved error handling and immediate flushing"""
237
+ try:
238
+ output = json.dumps(data)
239
+ print(output, flush=True) # Use flush=True for immediate output
240
+ logging.debug(f"Successfully sent response: {len(output)} chars")
241
+
242
+ except BrokenPipeError:
243
+ logging.error("Broken pipe error - client disconnected")
244
+ sys.exit(0)
245
+
246
+ except Exception as e:
247
+ logging.error(f"Print error: {e}")
248
+ # Don't exit on print errors, just log them
249
+
250
+ def process_request(self, line: str) -> None:
251
+ """Process a single request line with improved error handling"""
252
+ try:
253
+ request = json.loads(line)
254
+ request_id = request.get("id", "unknown")
255
+ method = request.get("method", "unknown")
256
+
257
+ logging.debug(f"Processing request {request_id}: {method}")
258
+
259
+ # Validate required fields
260
+ if not isinstance(request, dict):
261
+ raise ValueError("Request must be a JSON object")
262
+
263
+ if "method" not in request:
264
+ raise ValueError("Request must have a 'method' field")
265
+
266
+ # Handle notifications/initialized locally (Malloy server doesn't support it)
267
+ if method == "notifications/initialized":
268
+ logging.info(f"Handling notifications/initialized locally for request {request_id}")
269
+ # For notifications, we don't send a response (notifications are one-way)
270
+ return
271
+
272
+ # Ensure ID is present and valid
273
+ if "id" not in request:
274
+ request["id"] = 1 # Default ID
275
+ elif request["id"] is None:
276
+ request["id"] = 1 # Replace null with default
277
+
278
+ # Send request and get response
279
+ response = self.send_request(request)
280
+
281
+ # Send response immediately
282
+ self.safe_print(response)
283
+
284
+ except json.JSONDecodeError as e:
285
+ logging.error(f"JSON parse error: {e}")
286
+ error_response = {
287
+ "jsonrpc": "2.0",
288
+ "error": {
289
+ "code": -32700,
290
+ "message": f"Parse error: {str(e)}"
291
+ },
292
+ "id": None
293
+ }
294
+ self.safe_print(error_response)
295
+
296
+ except ValueError as e:
297
+ logging.error(f"Request validation error: {e}")
298
+ error_response = {
299
+ "jsonrpc": "2.0",
300
+ "error": {
301
+ "code": -32600,
302
+ "message": f"Invalid Request: {str(e)}"
303
+ },
304
+ "id": None
305
+ }
306
+ self.safe_print(error_response)
307
+
308
+ except Exception as e:
309
+ logging.error(f"Unexpected error processing request: {e}")
310
+ error_response = {
311
+ "jsonrpc": "2.0",
312
+ "error": {
313
+ "code": -32603,
314
+ "message": f"Internal error: {str(e)}"
315
+ },
316
+ "id": None
317
+ }
318
+ self.safe_print(error_response)
319
+
320
+ def run(self):
321
+ """Main loop with improved stdin handling"""
322
+ logging.info("Starting main processing loop")
323
+
324
+ try:
325
+ # Process stdin line by line with immediate handling
326
+ for line in sys.stdin:
327
+ line = line.strip()
328
+ if not line:
329
+ continue
330
+
331
+ # Process each request immediately
332
+ self.process_request(line)
333
+
334
+ except KeyboardInterrupt:
335
+ logging.info("Received keyboard interrupt, shutting down")
336
+ sys.exit(0)
337
+
338
+ except BrokenPipeError:
339
+ logging.info("Broken pipe detected, client disconnected")
340
+ sys.exit(0)
341
+
342
+ except Exception as e:
343
+ logging.error(f"Fatal error in main loop: {e}")
344
+ sys.exit(1)
345
+
346
+ logging.info("Main loop completed")
347
+
348
+ if __name__ == "__main__":
349
+ try:
350
+ bridge = ImprovedMalloyMCPBridge()
351
+ bridge.run()
352
+ except Exception as e:
353
+ logging.error(f"Failed to start bridge: {e}")
354
+ 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.76",
4
+ "version": "0.0.78",
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
+ }