@malloy-publisher/server 0.0.226 → 0.0.228

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.
Files changed (112) hide show
  1. package/README.docker.md +8 -8
  2. package/README.md +2 -11
  3. package/dist/app/assets/{EnvironmentPage-DvOJ7L_b.js → EnvironmentPage-EW2lbGvb.js} +1 -1
  4. package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-Bkwc9Woc.js} +1 -1
  5. package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-Bum_KBpN.js} +1 -1
  6. package/dist/app/assets/{MainPage-BIe0VwBa.js → MainPage-oiEy7TNM.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-BuZ6UJVx.js → MaterializationsPage-C_VJsTgU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-DsPf-s8B.js → ModelPage-z8REqAmk.js} +1 -1
  9. package/dist/app/assets/{PackagePage-CEVNAKZa.js → PackagePage-C2Vtt1Ln.js} +1 -1
  10. package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-DmJLpLXm.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DWC_FdNU.js → ThemeEditorPage-BywFjC7A.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-CGrsFz8p.js → WorkbookPage-DCMizDMR.js} +1 -1
  13. package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-CEDZMHV1.es-_yGzNgNH.js} +1 -1
  14. package/dist/app/assets/{index-D6YtyiJ0.js → index-CE9xhdra.js} +1 -1
  15. package/dist/app/assets/{index-BioohWQj.js → index-CdmFub34.js} +1 -1
  16. package/dist/app/assets/{index-gEWxu09x.js → index-DDMrjIT3.js} +1 -1
  17. package/dist/app/assets/{index-DNUZpnaa.js → index-EqslXZ44.js} +4 -4
  18. package/dist/app/index.html +1 -1
  19. package/dist/default-publisher.config.json +7 -7
  20. package/dist/package_load_worker.mjs +86 -24
  21. package/dist/runtime/publisher.js +5 -0
  22. package/dist/server.mjs +1415 -7876
  23. package/package.json +1 -4
  24. package/publisher.config.example.bigquery.json +7 -7
  25. package/publisher.config.example.duckdb.json +7 -7
  26. package/publisher.config.json +7 -11
  27. package/src/config.spec.ts +2 -2
  28. package/src/controller/package.controller.ts +62 -31
  29. package/src/default-publisher.config.json +7 -7
  30. package/src/health.ts +3 -8
  31. package/src/mcp/error_messages.ts +8 -37
  32. package/src/mcp/handler_utils.spec.ts +108 -0
  33. package/src/mcp/handler_utils.ts +99 -124
  34. package/src/mcp/mcp_constants.ts +0 -16
  35. package/src/mcp/server.protocol.spec.ts +138 -0
  36. package/src/mcp/server.ts +57 -37
  37. package/src/mcp/skills/build_skills_bundle.ts +1 -1
  38. package/src/mcp/skills/skills_bundle.json +1 -1
  39. package/src/mcp/tools/compile_tool.spec.ts +207 -0
  40. package/src/mcp/tools/compile_tool.ts +177 -0
  41. package/src/mcp/tools/docs_search_tool.ts +1 -1
  42. package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
  43. package/src/mcp/tools/execute_query_tool.ts +39 -6
  44. package/src/mcp/tools/get_context_eval.ts +3 -3
  45. package/src/mcp/tools/get_context_tool.spec.ts +196 -1
  46. package/src/mcp/tools/get_context_tool.ts +165 -67
  47. package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
  48. package/src/mcp/tools/reload_package_tool.ts +158 -0
  49. package/src/package_load/package_load_pool.ts +3 -0
  50. package/src/package_load/package_load_worker.ts +68 -24
  51. package/src/package_load/protocol.ts +16 -0
  52. package/src/package_load/rpc_wait_accountant.spec.ts +109 -0
  53. package/src/package_load/rpc_wait_accountant.ts +76 -0
  54. package/src/package_load_metrics.spec.ts +114 -0
  55. package/src/package_load_metrics.ts +127 -0
  56. package/src/pg_helpers.spec.ts +2 -206
  57. package/src/pg_helpers.ts +4 -120
  58. package/src/runtime/publisher.js +5 -0
  59. package/src/server.ts +7 -21
  60. package/src/service/environment.ts +71 -7
  61. package/src/service/model.spec.ts +92 -0
  62. package/src/service/model.ts +58 -7
  63. package/src/service/package.ts +113 -55
  64. package/src/service/package_reload_safety.spec.ts +193 -0
  65. package/src/test_helpers/metrics_harness.ts +40 -0
  66. package/tests/fixtures/query-givens/data/orders.csv +7 -0
  67. package/tests/fixtures/query-givens/model.malloy +34 -0
  68. package/tests/fixtures/query-givens/publisher.json +5 -0
  69. package/tests/harness/mcp_test_setup.ts +1 -1
  70. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
  71. package/tests/integration/mcp/mcp_transport.integration.spec.ts +7 -31
  72. package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
  73. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
  74. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
  75. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -6
  76. package/dxt/malloy_bridge.py +0 -354
  77. package/dxt/manifest.json +0 -22
  78. package/src/dto/connection.dto.spec.ts +0 -186
  79. package/src/dto/connection.dto.ts +0 -308
  80. package/src/dto/index.ts +0 -2
  81. package/src/dto/package.dto.spec.ts +0 -42
  82. package/src/dto/package.dto.ts +0 -27
  83. package/src/dto/validate.spec.ts +0 -76
  84. package/src/dto/validate.ts +0 -31
  85. package/src/ducklake_version.spec.ts +0 -43
  86. package/src/ducklake_version.ts +0 -26
  87. package/src/mcp/agent_server.protocol.spec.ts +0 -78
  88. package/src/mcp/agent_server.spec.ts +0 -18
  89. package/src/mcp/agent_server.ts +0 -144
  90. package/src/mcp/prompts/handlers.ts +0 -84
  91. package/src/mcp/prompts/index.ts +0 -11
  92. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  93. package/src/mcp/prompts/prompt_service.ts +0 -67
  94. package/src/mcp/prompts/utils.ts +0 -62
  95. package/src/mcp/resource_metadata.ts +0 -47
  96. package/src/mcp/resources/environment_resource.ts +0 -187
  97. package/src/mcp/resources/model_resource.ts +0 -155
  98. package/src/mcp/resources/notebook_resource.ts +0 -137
  99. package/src/mcp/resources/package_resource.ts +0 -373
  100. package/src/mcp/resources/query_resource.ts +0 -122
  101. package/src/mcp/resources/source_resource.ts +0 -141
  102. package/src/mcp/resources/view_resource.ts +0 -136
  103. package/src/mcp/tools/discovery_tools.ts +0 -280
  104. package/src/storage/BaseRepository.ts +0 -31
  105. package/src/storage/StorageManager.mock.ts +0 -50
  106. package/tests/harness/e2e.ts +0 -96
  107. package/tests/harness/mocks.ts +0 -39
  108. package/tests/harness/uris.ts +0 -31
  109. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -655
  110. package/tests/integration/mcp/setup.spec.ts +0 -5
  111. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  112. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
@@ -0,0 +1,121 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * HTTP E2E for the givens × `#(authorize)` interaction. Malloy is the single
5
+ * given validator, so on a GATED source a bad given (unknown NAME or wrong-typed
6
+ * VALUE) is caught inside the authorize probe, which fails closed — surfacing as
7
+ * a 403, not the 400 the ungated path returns. This suite pins that documented
8
+ * asymmetry so it stays intentional:
9
+ *
10
+ * - unknown given name -> 403 (probe can't bind it -> fails closed)
11
+ * - authorized + valid givens -> 200 (retargets rows)
12
+ * - authorize denies -> 403
13
+ * - valid name, BAD value -> 403 (probe can't evaluate it -> fails closed)
14
+ *
15
+ * On an UNGATED source the same unknown name / bad value are a clean 400 (Malloy
16
+ * `runtime-given-*` mapped by model.ts) — see query_givens.integration.spec.ts.
17
+ *
18
+ * See packages/server/src/service/authorize.ts (evaluateAuthorize, fail-closed).
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
22
+ import path from "path";
23
+ import { fileURLToPath } from "url";
24
+ import { type RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const ENV_NAME = "query-givens-authz-env";
28
+ const PKG = "query-givens";
29
+ const MODEL = "model.malloy";
30
+
31
+ type Row = Record<string, unknown>;
32
+
33
+ describe("givens × authorize on /query (HTTP E2E)", () => {
34
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
35
+ let baseUrl: string;
36
+
37
+ beforeAll(async () => {
38
+ env = await startRestE2E();
39
+ baseUrl = env.baseUrl;
40
+ const fixtureDir = path.resolve(__dirname, "../../fixtures/query-givens");
41
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({
45
+ name: ENV_NAME,
46
+ packages: [{ name: PKG, location: fixtureDir }],
47
+ connections: [],
48
+ }),
49
+ });
50
+ if (!createRes.ok) {
51
+ throw new Error(
52
+ `Failed to create test environment (${createRes.status}): ${await createRes.text()}`,
53
+ );
54
+ }
55
+ const deadline = Date.now() + 30_000;
56
+ while (Date.now() < deadline) {
57
+ const res = await fetch(
58
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PKG}`,
59
+ );
60
+ if (res.ok) break;
61
+ await new Promise((r) => setTimeout(r, 250));
62
+ }
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
67
+ method: "DELETE",
68
+ }).catch(() => {});
69
+ await env?.stop();
70
+ });
71
+
72
+ const queryGated = (body: Record<string, unknown>) =>
73
+ fetch(
74
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PKG}/models/${MODEL}/query`,
75
+ {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify({
79
+ sourceName: "gated_orders",
80
+ queryName: "by_given_region",
81
+ compactJson: true,
82
+ ...body,
83
+ }),
84
+ },
85
+ );
86
+
87
+ it("unknown given name -> 403 on a gated source (authorize probe fails closed)", async () => {
88
+ // Even with role=admin, the unknown given makes the authorize probe throw
89
+ // (`runtime-given-unknown`), which the gate treats as not-granting. On an
90
+ // ungated source the same name is a clean 400 (see query_givens suite).
91
+ const res = await queryGated({
92
+ givens: { role: "admin", NOtaGiven: 1 },
93
+ });
94
+ expect(res.status).toBe(403);
95
+ });
96
+
97
+ it("authorized caller with valid givens -> 200 and retargets rows", async () => {
98
+ const res = await queryGated({
99
+ givens: { role: "admin", target_region: "EU" },
100
+ });
101
+ expect(res.status).toBe(200);
102
+ const body = (await res.json()) as { result: string };
103
+ const r = JSON.parse(body.result) as Row[];
104
+ expect(Number(r[0].order_count)).toBe(3);
105
+ });
106
+
107
+ it("authorize deny (non-admin role) -> 403", async () => {
108
+ const res = await queryGated({ givens: { role: "guest" } });
109
+ expect(res.status).toBe(403);
110
+ });
111
+
112
+ it("valid given name with a bad value -> 403 on a gated source (fail-closed authorize)", async () => {
113
+ // The authorize probe binds the supplied givens; a value it can't evaluate
114
+ // makes the probe throw and the gate denies. On the UNGATED path the same
115
+ // bad value is a 400 (Malloy at prepare time) — this asymmetry is by design.
116
+ const res = await queryGated({
117
+ givens: { role: "admin", min_amount: "not-a-number" },
118
+ });
119
+ expect(res.status).toBe(403);
120
+ });
121
+ });
@@ -0,0 +1,110 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * Proves the browser SDK (`packages/server/src/runtime/publisher.js`, served
5
+ * at GET /sdk/publisher.js) actually puts `givens` on the wire: fetch the
6
+ * served source, evaluate it in a sandboxed vm context standing in for
7
+ * `window` (self === top, so the in-iframe/live-reload branches are
8
+ * inert no-ops), and call `window.Publisher.query(...)` with a `givens`
9
+ * override against the real HTTP server. Approach used: sandbox-eval via
10
+ * Node's `vm` module — evaluating the actual runtime rather than just
11
+ * grepping its source proves the SDK's fetch body really carries `givens`
12
+ * end-to-end.
13
+ */
14
+
15
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
16
+ import path from "path";
17
+ import { fileURLToPath } from "url";
18
+ import vm from "vm";
19
+ import { type RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const ENV_NAME = "sdk-givens-env";
23
+ const PKG = "query-givens";
24
+ const MODEL = "model.malloy";
25
+
26
+ type Row = Record<string, unknown>;
27
+
28
+ describe("Publisher SDK forwards givens (sandbox-eval HTTP E2E)", () => {
29
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
30
+ let baseUrl: string;
31
+
32
+ beforeAll(async () => {
33
+ env = await startRestE2E();
34
+ baseUrl = env.baseUrl;
35
+ const fixtureDir = path.resolve(__dirname, "../../fixtures/query-givens");
36
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({
40
+ name: ENV_NAME,
41
+ packages: [{ name: PKG, location: fixtureDir }],
42
+ connections: [],
43
+ }),
44
+ });
45
+ if (!createRes.ok) {
46
+ throw new Error(
47
+ `Failed to create test environment (${createRes.status}): ${await createRes.text()}`,
48
+ );
49
+ }
50
+ const deadline = Date.now() + 30_000;
51
+ while (Date.now() < deadline) {
52
+ const res = await fetch(
53
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PKG}`,
54
+ );
55
+ if (res.ok) break;
56
+ await new Promise((r) => setTimeout(r, 250));
57
+ }
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
62
+ method: "DELETE",
63
+ }).catch(() => {});
64
+ await env?.stop();
65
+ });
66
+
67
+ it("Publisher.query() with a givens override reaches the server and retargets rows", async () => {
68
+ const sdkRes = await fetch(`${baseUrl}/sdk/publisher.js`);
69
+ expect(sdkRes.status).toBe(200);
70
+ const sdkSource = await sdkRes.text();
71
+
72
+ // Sandbox stands in for `window`. self === top so the runtime's
73
+ // in-iframe (postMessage resize) and live-reload (EventSource) branches
74
+ // are no-ops; document/EventSource are omitted since that code path
75
+ // never runs for a non-embedded page.
76
+ const sandbox: Record<string, unknown> = {
77
+ fetch,
78
+ location: {
79
+ pathname: `/environments/${ENV_NAME}/packages/${PKG}/`,
80
+ origin: baseUrl,
81
+ },
82
+ console,
83
+ };
84
+ sandbox.self = sandbox;
85
+ sandbox.top = sandbox;
86
+ sandbox.window = sandbox;
87
+
88
+ const context = vm.createContext(sandbox);
89
+ vm.runInContext(sdkSource, context, { filename: "publisher.js" });
90
+
91
+ const publisher = (sandbox.window as { Publisher: unknown })
92
+ .Publisher as {
93
+ query: (
94
+ modelPath: string,
95
+ malloyQuery: string | undefined,
96
+ opts: Record<string, unknown>,
97
+ ) => Promise<Row[]>;
98
+ };
99
+ expect(publisher).toBeDefined();
100
+
101
+ const rows = await publisher.query(MODEL, undefined, {
102
+ sourceName: "orders",
103
+ queryName: "by_given_region",
104
+ givens: { target_region: "EU" },
105
+ });
106
+
107
+ expect(rows.length).toBe(1);
108
+ expect(Number(rows[0].order_count)).toBe(3);
109
+ });
110
+ });
@@ -116,11 +116,6 @@ async function startServer(opts: { watch: boolean }): Promise<TestServer> {
116
116
  const port = await getFreePort();
117
117
  let mcpPort = await getFreePort();
118
118
  while (mcpPort === port) mcpPort = await getFreePort();
119
- // The agent MCP server binds its own listener; give it a distinct free port
120
- // too, otherwise every spawned child binds the fixed default and collides.
121
- let agentMcpPort = await getFreePort();
122
- while (agentMcpPort === port || agentMcpPort === mcpPort)
123
- agentMcpPort = await getFreePort();
124
119
  const baseUrl = `http://127.0.0.1:${port}`;
125
120
 
126
121
  const env: NodeJS.ProcessEnv = {
@@ -129,7 +124,6 @@ async function startServer(opts: { watch: boolean }): Promise<TestServer> {
129
124
  PUBLISHER_HOST: "127.0.0.1",
130
125
  PUBLISHER_PORT: String(port),
131
126
  MCP_PORT: String(mcpPort),
132
- AGENT_MCP_PORT: String(agentMcpPort),
133
127
  };
134
128
  if (opts.watch) env.PUBLISHER_WATCH = ENV_NAME;
135
129
 
@@ -1,354 +0,0 @@
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)
package/dxt/manifest.json DELETED
@@ -1,22 +0,0 @@
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
- }