mcp-server-motherduck 0.6.4__py3-none-any.whl → 1.0.0__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.
@@ -1,193 +1,258 @@
1
- import anyio
1
+ """
2
+ MotherDuck MCP Server - A FastMCP server for DuckDB and MotherDuck.
3
+
4
+ This module provides the CLI entry point for the MCP server.
5
+ """
6
+
2
7
  import logging
8
+ import warnings
9
+
3
10
  import click
4
- from .server import build_application
5
- from .configs import SERVER_VERSION, SERVER_LOCALHOST, UVICORN_LOGGING_CONFIG
11
+
12
+ from .configs import SERVER_LOCALHOST, SERVER_VERSION
13
+ from .server import create_mcp_server
6
14
 
7
15
  __version__ = SERVER_VERSION
8
16
 
9
17
  logger = logging.getLogger("mcp_server_motherduck")
10
- logging.basicConfig(
11
- level=logging.INFO, format="[motherduck] %(levelname)s - %(message)s"
12
- )
18
+ logging.basicConfig(level=logging.INFO, format="[motherduck] %(levelname)s - %(message)s")
13
19
 
14
20
 
15
21
  @click.command()
16
- @click.option("--port", default=8000, help="Port to listen on for SSE")
22
+ @click.option(
23
+ "--port", default=8000, envvar="MCP_PORT", help="Port to listen on for HTTP transport"
24
+ )
25
+ @click.option(
26
+ "--host", default=SERVER_LOCALHOST, envvar="MCP_HOST", help="Host to bind the MCP server"
27
+ )
17
28
  @click.option(
18
29
  "--transport",
19
- type=click.Choice(["stdio", "sse", "stream"]),
30
+ type=click.Choice(["stdio", "http", "sse", "stream"]),
20
31
  default="stdio",
21
- help="(Default: `stdio`) Transport type",
32
+ envvar="MCP_TRANSPORT",
33
+ help="(Default: `stdio`) Transport type. Use `http` for HTTP Streamable transport. `sse` and `stream` are deprecated aliases.",
22
34
  )
23
35
  @click.option(
24
36
  "--db-path",
25
- default="md:",
26
- help="(Default: `md:`) Path to local DuckDB database file or MotherDuck database",
37
+ default=":memory:",
38
+ envvar="MCP_DB_PATH",
39
+ help="(Default: `:memory:`) Path to local DuckDB database file or MotherDuck database",
27
40
  )
28
41
  @click.option(
29
42
  "--motherduck-token",
30
43
  default=None,
31
- help="(Default: env var `motherduck_token`) Access token to use for MotherDuck database connections",
44
+ envvar=["motherduck_token", "MOTHERDUCK_TOKEN"],
45
+ help="(Default: env var `motherduck_token` or `MOTHERDUCK_TOKEN`) Access token to use for MotherDuck database connections",
32
46
  )
33
47
  @click.option(
34
48
  "--home-dir",
35
49
  default=None,
36
- help="(Default: env var `HOME`) Home directory for DuckDB",
50
+ help="Override the home directory for DuckDB (defaults to system HOME)",
37
51
  )
38
52
  @click.option(
39
- "--saas-mode",
53
+ "--motherduck-saas-mode",
40
54
  is_flag=True,
55
+ envvar="MCP_SAAS_MODE",
41
56
  help="Flag for connecting to MotherDuck in SaaS mode",
42
57
  )
58
+ @click.option(
59
+ "--read-write",
60
+ is_flag=True,
61
+ envvar="MCP_READ_WRITE",
62
+ help="Enable write access to the database. By default, the server runs in read-only mode for local DuckDB files and MotherDuck databases. Note: In-memory databases are always writable (DuckDB limitation).",
63
+ )
64
+ @click.option(
65
+ "--ephemeral-connections/--no-ephemeral-connections",
66
+ default=True,
67
+ envvar="MCP_EPHEMERAL_CONNECTIONS",
68
+ help="Use temporary connections for read-only local DuckDB files, creating a new connection for each query. This keeps the file unlocked so other processes can write to it.",
69
+ )
70
+ @click.option(
71
+ "--max-rows",
72
+ type=int,
73
+ default=1024,
74
+ envvar="MCP_MAX_ROWS",
75
+ help="(Default: `1024`) Maximum number of rows to return from queries. Use LIMIT in your SQL for specific row counts.",
76
+ )
77
+ @click.option(
78
+ "--max-chars",
79
+ type=int,
80
+ default=50000,
81
+ envvar="MCP_MAX_CHARS",
82
+ help="(Default: `50000`) Maximum number of characters in query results. Prevents issues with wide rows or large text columns.",
83
+ )
84
+ @click.option(
85
+ "--query-timeout",
86
+ type=int,
87
+ default=-1,
88
+ envvar="MCP_QUERY_TIMEOUT",
89
+ help="(Default: `-1`) Query execution timeout in seconds. Set to -1 to disable timeout.",
90
+ )
91
+ @click.option(
92
+ "--init-sql",
93
+ default=None,
94
+ envvar="MCP_INIT_SQL",
95
+ help="SQL file path or SQL string to execute on startup for database initialization.",
96
+ )
97
+ @click.option(
98
+ "--allow-switch-databases",
99
+ is_flag=True,
100
+ envvar="MCP_ALLOW_SWITCH_DATABASES",
101
+ help="Enable the switch_database_connection tool to change databases at runtime. Disabled by default.",
102
+ )
103
+ # Backwards compatibility aliases (deprecated)
104
+ @click.option(
105
+ "--saas-mode",
106
+ is_flag=True,
107
+ hidden=True,
108
+ help="[DEPRECATED] Use --motherduck-saas-mode instead.",
109
+ )
43
110
  @click.option(
44
111
  "--read-only",
45
112
  is_flag=True,
46
- help="Flag for connecting to DuckDB in read-only mode. Only supported for local DuckDB databases. Also makes use of short lived connections so multiple MCP clients or other systems can remain active (though each operation must be done sequentially).",
113
+ hidden=True,
114
+ help="[DEPRECATED] Read-only is now the default. Use --read-write for write access.",
47
115
  )
48
116
  @click.option(
49
117
  "--json-response",
50
118
  is_flag=True,
51
- default=False,
52
- help="(Default: `False`) Enable JSON responses instead of SSE streams. Only supported for `stream` transport.",
119
+ hidden=True,
120
+ help="[DEPRECATED] No longer needed, JSON responses are automatic.",
53
121
  )
54
122
  def main(
55
- port,
56
- transport,
57
- db_path,
58
- motherduck_token,
59
- home_dir,
60
- saas_mode,
61
- read_only,
62
- json_response,
63
- ):
64
- """Main entry point for the package."""
65
-
66
- logger.info("🦆 MotherDuck MCP Server v" + SERVER_VERSION)
67
- logger.info("Ready to execute SQL queries via DuckDB/MotherDuck")
68
-
69
- app, init_opts = build_application(
70
- db_path=db_path,
71
- motherduck_token=motherduck_token,
72
- home_dir=home_dir,
73
- saas_mode=saas_mode,
74
- read_only=read_only,
75
- )
76
-
77
- if transport == "sse":
78
- from mcp.server.sse import SseServerTransport
79
- from starlette.applications import Starlette
80
- from starlette.responses import Response
81
- from starlette.routing import Mount, Route
82
-
83
- logger.info("MCP server initialized in \033[32msse\033[0m mode")
84
-
85
- sse = SseServerTransport("/messages/")
86
-
87
- async def handle_sse(request):
88
- async with sse.connect_sse(
89
- request.scope, request.receive, request._send
90
- ) as (read_stream, write_stream):
91
- await app.run(read_stream, write_stream, init_opts)
92
- return Response()
93
-
94
- logger.info(
95
- f"🦆 Connect to MotherDuck MCP Server at \033[1m\033[36mhttp://{SERVER_LOCALHOST}:{port}/sse\033[0m"
123
+ port: int,
124
+ host: str,
125
+ transport: str,
126
+ db_path: str,
127
+ motherduck_token: str | None,
128
+ home_dir: str | None,
129
+ motherduck_saas_mode: bool,
130
+ read_write: bool,
131
+ ephemeral_connections: bool,
132
+ max_rows: int,
133
+ max_chars: int,
134
+ query_timeout: int,
135
+ init_sql: str | None,
136
+ allow_switch_databases: bool,
137
+ # Deprecated args
138
+ saas_mode: bool,
139
+ read_only: bool,
140
+ json_response: bool,
141
+ ) -> None:
142
+ """MotherDuck MCP Server - Execute SQL queries via DuckDB/MotherDuck."""
143
+ # Handle deprecated flags with warnings
144
+ if saas_mode:
145
+ warnings.warn(
146
+ "The '--saas-mode' flag is deprecated. Use '--motherduck-saas-mode' instead.",
147
+ DeprecationWarning,
148
+ stacklevel=2,
96
149
  )
150
+ logger.warning("⚠️ '--saas-mode' is deprecated. Use '--motherduck-saas-mode' instead.")
151
+ motherduck_saas_mode = True
97
152
 
98
- starlette_app = Starlette(
99
- debug=True,
100
- routes=[
101
- Route("/sse", endpoint=handle_sse, methods=["GET"]),
102
- Mount("/messages/", app=sse.handle_post_message),
103
- ],
153
+ if read_only:
154
+ warnings.warn(
155
+ "The '--read-only' flag is deprecated. Read-only is now the default. "
156
+ "Use '--read-write' for write access.",
157
+ DeprecationWarning,
158
+ stacklevel=2,
104
159
  )
105
-
106
- import uvicorn
107
-
108
- uvicorn.run(
109
- starlette_app,
110
- host=SERVER_LOCALHOST,
111
- port=port,
112
- log_config=UVICORN_LOGGING_CONFIG,
160
+ logger.warning(
161
+ "⚠️ '--read-only' is deprecated. Read-only is now the default. "
162
+ "Remove '--read-only' from your config."
113
163
  )
164
+ # read_only flag is effectively a no-op now since default is read-only
114
165
 
115
- elif transport == "stream":
116
- from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
117
- from collections.abc import AsyncIterator
118
- from starlette.applications import Starlette
119
- from starlette.routing import Mount
120
- from starlette.types import Receive, Scope, Send
121
- import contextlib
122
-
123
- logger.info("MCP server initialized in \033[32mhttp-streamable\033[0m mode")
124
-
125
- # Create the session manager with true stateless mode
126
- session_manager = StreamableHTTPSessionManager(
127
- app=app,
128
- event_store=None,
129
- json_response=json_response,
130
- stateless=True,
166
+ if json_response:
167
+ warnings.warn(
168
+ "The '--json-response' flag is deprecated and no longer needed.",
169
+ DeprecationWarning,
170
+ stacklevel=2,
131
171
  )
132
-
133
- async def handle_streamable_http(
134
- scope: Scope, receive: Receive, send: Send
135
- ) -> None:
136
- await session_manager.handle_request(scope, receive, send)
137
-
138
- @contextlib.asynccontextmanager
139
- async def lifespan(app: Starlette) -> AsyncIterator[None]:
140
- """Context manager for session manager."""
141
- async with session_manager.run():
142
- logger.info("MCP server started with StreamableHTTP session manager")
143
- try:
144
- yield
145
- finally:
146
- logger.info(
147
- "🦆 MotherDuck MCP Server in \033[32mhttp-streamable\033[0m mode shutting down"
148
- )
149
-
150
- logger.info(
151
- f"🦆 Connect to MotherDuck MCP Server at \033[1m\033[36mhttp://{SERVER_LOCALHOST}:{port}/mcp\033[0m"
172
+ logger.warning(
173
+ "⚠️ '--json-response' is deprecated and no longer needed. Remove it from your config."
152
174
  )
153
175
 
154
- # Create an ASGI application using the transport
155
- starlette_app = Starlette(
156
- debug=True,
157
- routes=[
158
- Mount("/mcp", app=handle_streamable_http),
159
- ],
160
- lifespan=lifespan,
161
- )
162
-
163
- import uvicorn
176
+ # Convert read_write flag to read_only (inverted logic)
177
+ actual_read_only = not read_write
164
178
 
165
- uvicorn.run(
166
- starlette_app,
167
- host=SERVER_LOCALHOST,
168
- port=port,
169
- log_config=UVICORN_LOGGING_CONFIG,
179
+ # In-memory databases require --read-write flag since read-only doesn't apply
180
+ if db_path == ":memory:" and actual_read_only:
181
+ raise click.UsageError(
182
+ "In-memory databases require the --read-write flag.\n"
183
+ "Options:\n"
184
+ " - Add --read-write to allow writes (data won't persist anyway)\n"
185
+ " - Use --db-path with a file path for read-only access to a DuckDB file\n"
186
+ " - Use --db-path md: with a MotherDuck token for cloud database access"
170
187
  )
171
188
 
189
+ logger.info("🦆 MotherDuck MCP Server v" + SERVER_VERSION)
190
+ logger.info("Ready to execute SQL queries via DuckDB/MotherDuck")
191
+ if db_path == ":memory:":
192
+ logger.info("Database mode: in-memory (read-write)")
172
193
  else:
173
- from mcp.server.stdio import stdio_server
194
+ mode_str = "read-write" if not actual_read_only else "read-only"
195
+ if actual_read_only and not ephemeral_connections:
196
+ mode_str += " (persistent connection)"
197
+ logger.info(f"Database mode: {mode_str}")
198
+ logger.info(f"Query result limits: {max_rows} rows, {max_chars:,} characters")
199
+ if query_timeout == -1:
200
+ logger.info("Query timeout: disabled")
201
+ else:
202
+ logger.info(f"Query timeout: {query_timeout}s")
203
+ if init_sql:
204
+ logger.info("Init SQL: configured")
205
+ if allow_switch_databases:
206
+ logger.info("Switch databases: enabled")
174
207
 
175
- logger.info("MCP server initialized in \033[32mstdio\033[0m mode")
176
- logger.info("Waiting for client connection")
208
+ # Handle deprecated transport aliases
209
+ if transport == "stream":
210
+ warnings.warn(
211
+ "The 'stream' transport is deprecated. Use 'http' instead.",
212
+ DeprecationWarning,
213
+ stacklevel=2,
214
+ )
215
+ logger.warning("⚠️ '--transport stream' is deprecated. Use '--transport http' instead.")
216
+ transport = "http"
217
+ elif transport == "sse":
218
+ warnings.warn(
219
+ "The 'sse' transport is deprecated. Use 'http' instead.",
220
+ DeprecationWarning,
221
+ stacklevel=2,
222
+ )
223
+ logger.warning("⚠️ '--transport sse' is deprecated. Use '--transport http' instead.")
224
+ transport = "http"
177
225
 
178
- async def arun():
179
- async with stdio_server() as (read_stream, write_stream):
180
- await app.run(read_stream, write_stream, init_opts)
226
+ # Create the FastMCP server
227
+ mcp = create_mcp_server(
228
+ db_path=db_path,
229
+ motherduck_token=motherduck_token,
230
+ home_dir=home_dir,
231
+ saas_mode=motherduck_saas_mode,
232
+ read_only=actual_read_only,
233
+ ephemeral_connections=ephemeral_connections,
234
+ max_rows=max_rows,
235
+ max_chars=max_chars,
236
+ query_timeout=query_timeout,
237
+ init_sql=init_sql,
238
+ allow_switch_databases=allow_switch_databases,
239
+ )
181
240
 
182
- anyio.run(arun)
183
- # This will only be reached when the server is shutting down
241
+ # Run the server with the appropriate transport
242
+ if transport == "http":
243
+ logger.info("MCP server initialized in \033[32mhttp\033[0m mode")
184
244
  logger.info(
185
- "🦆 MotherDuck MCP Server in \033[32mstdio\033[0m mode shutting down"
245
+ f"🦆 Connect to MotherDuck MCP Server at \033[1m\033[36mhttp://{host}:{port}/mcp\033[0m"
186
246
  )
247
+ mcp.run(transport="http", host=host, port=port)
248
+ else:
249
+ logger.info("MCP server initialized in \033[32mstdio\033[0m mode")
250
+ logger.info("Waiting for client connection")
251
+ mcp.run(transport="stdio")
187
252
 
188
253
 
189
254
  # Optionally expose other important items at package level
190
- __all__ = ["main"]
255
+ __all__ = ["main", "__version__"]
191
256
 
192
257
  if __name__ == "__main__":
193
258
  main()
@@ -1,6 +1,6 @@
1
1
  from typing import Any
2
2
 
3
- SERVER_VERSION = "0.6.4"
3
+ SERVER_VERSION = "1.0.0"
4
4
 
5
5
  SERVER_LOCALHOST = "127.0.0.1"
6
6