mcp-server-motherduck 0.5.1__py3-none-any.whl → 0.6.1__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 mcp-server-motherduck might be problematic. Click here for more details.
- mcp_server_motherduck/__init__.py +179 -52
- mcp_server_motherduck/configs.py +32 -0
- mcp_server_motherduck/database.py +134 -0
- mcp_server_motherduck/server.py +13 -164
- {mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/METADATA +54 -35
- mcp_server_motherduck-0.6.1.dist-info/RECORD +10 -0
- mcp_server_motherduck-0.5.1.dist-info/RECORD +0 -8
- {mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/WHEEL +0 -0
- {mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/entry_points.txt +0 -0
- {mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,66 +1,193 @@
|
|
|
1
|
-
|
|
2
|
-
import asyncio
|
|
3
|
-
import argparse
|
|
1
|
+
import anyio
|
|
4
2
|
import logging
|
|
3
|
+
import click
|
|
4
|
+
from .server import build_application
|
|
5
|
+
from .configs import SERVER_VERSION, SERVER_LOCALHOST, UVICORN_LOGGING_CONFIG
|
|
6
|
+
|
|
7
|
+
__version__ = SERVER_VERSION
|
|
5
8
|
|
|
6
9
|
logger = logging.getLogger("mcp_server_motherduck")
|
|
7
|
-
logging.basicConfig(
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO, format="[motherduck] %(levelname)s - %(message)s"
|
|
12
|
+
)
|
|
8
13
|
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option("--port", default=8000, help="Port to listen on for SSE")
|
|
17
|
+
@click.option(
|
|
18
|
+
"--transport",
|
|
19
|
+
type=click.Choice(["stdio", "sse", "stream"]),
|
|
20
|
+
default="stdio",
|
|
21
|
+
help="(Default: `stdio`) Transport type",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--db-path",
|
|
25
|
+
default="md:",
|
|
26
|
+
help="(Default: `md:`) Path to local DuckDB database file or MotherDuck database",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--motherduck-token",
|
|
30
|
+
default=None,
|
|
31
|
+
help="(Default: env var `motherduck_token`) Access token to use for MotherDuck database connections",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--home-dir",
|
|
35
|
+
default=None,
|
|
36
|
+
help="(Default: env var `HOME`) Home directory for DuckDB",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--saas-mode",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
help="Flag for connecting to MotherDuck in SaaS mode",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--read-only",
|
|
45
|
+
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).",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--json-response",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
default=False,
|
|
52
|
+
help="(Default: `False`) Enable JSON responses instead of SSE streams. Only supported for `stream` transport.",
|
|
53
|
+
)
|
|
54
|
+
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
|
+
):
|
|
11
64
|
"""Main entry point for the package."""
|
|
12
65
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"--db-path",
|
|
16
|
-
default="md:",
|
|
17
|
-
help="(Default: `md:`) Path to local DuckDB database file or MotherDuck database",
|
|
18
|
-
)
|
|
19
|
-
parser.add_argument(
|
|
20
|
-
"--motherduck-token",
|
|
21
|
-
default=None,
|
|
22
|
-
help="(Default: env var `motherduck_token`) Access token to use for MotherDuck database connections",
|
|
23
|
-
)
|
|
24
|
-
parser.add_argument(
|
|
25
|
-
"--home-dir",
|
|
26
|
-
default=None,
|
|
27
|
-
help="(Default: env var `HOME`) Home directory for DuckDB",
|
|
28
|
-
)
|
|
29
|
-
parser.add_argument(
|
|
30
|
-
"--saas-mode",
|
|
31
|
-
action="store_true",
|
|
32
|
-
help="Flag for connecting to MotherDuck in SaaS mode",
|
|
33
|
-
)
|
|
34
|
-
# This is experimental and will change in the future
|
|
35
|
-
parser.add_argument(
|
|
36
|
-
"--result-format",
|
|
37
|
-
help="(Default: `markdown`) Format of the query result",
|
|
38
|
-
default="markdown",
|
|
39
|
-
choices=["markdown", "duckbox", "text"],
|
|
40
|
-
)
|
|
66
|
+
logger.info("🦆 MotherDuck MCP Server v" + SERVER_VERSION)
|
|
67
|
+
logger.info("Ready to execute SQL queries via DuckDB/MotherDuck")
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,
|
|
46
75
|
)
|
|
47
76
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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"
|
|
96
|
+
)
|
|
97
|
+
|
|
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
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
import uvicorn
|
|
107
|
+
|
|
108
|
+
uvicorn.run(
|
|
109
|
+
starlette_app,
|
|
110
|
+
host=SERVER_LOCALHOST,
|
|
111
|
+
port=port,
|
|
112
|
+
log_config=UVICORN_LOGGING_CONFIG,
|
|
113
|
+
)
|
|
114
|
+
|
|
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,
|
|
131
|
+
)
|
|
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"
|
|
152
|
+
)
|
|
153
|
+
|
|
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
|
|
164
|
+
|
|
165
|
+
uvicorn.run(
|
|
166
|
+
starlette_app,
|
|
167
|
+
host=SERVER_LOCALHOST,
|
|
168
|
+
port=port,
|
|
169
|
+
log_config=UVICORN_LOGGING_CONFIG,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
else:
|
|
173
|
+
from mcp.server.stdio import stdio_server
|
|
174
|
+
|
|
175
|
+
logger.info("MCP server initialized in \033[32mstdio\033[0m mode")
|
|
176
|
+
logger.info("Waiting for client connection")
|
|
177
|
+
|
|
178
|
+
async def arun():
|
|
179
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
180
|
+
await app.run(read_stream, write_stream, init_opts)
|
|
181
|
+
|
|
182
|
+
anyio.run(arun)
|
|
183
|
+
# This will only be reached when the server is shutting down
|
|
184
|
+
logger.info(
|
|
185
|
+
"🦆 MotherDuck MCP Server in \033[32mstdio\033[0m mode shutting down"
|
|
61
186
|
)
|
|
62
|
-
)
|
|
63
187
|
|
|
64
188
|
|
|
65
189
|
# Optionally expose other important items at package level
|
|
66
|
-
__all__ = ["main"
|
|
190
|
+
__all__ = ["main"]
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
SERVER_VERSION = "0.6.1"
|
|
4
|
+
|
|
5
|
+
SERVER_LOCALHOST = "127.0.0.1"
|
|
6
|
+
|
|
7
|
+
UVICORN_LOGGING_CONFIG: dict[str, Any] = {
|
|
8
|
+
"version": 1,
|
|
9
|
+
"disable_existing_loggers": False,
|
|
10
|
+
"formatters": {
|
|
11
|
+
"default": {
|
|
12
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
13
|
+
"fmt": "[uvicorn] %(levelname)s - %(message)s",
|
|
14
|
+
"use_colors": None,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"handlers": {
|
|
18
|
+
"default": {
|
|
19
|
+
"formatter": "default",
|
|
20
|
+
"class": "logging.StreamHandler",
|
|
21
|
+
"stream": "ext://sys.stderr",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
"loggers": {
|
|
25
|
+
"uvicorn": {
|
|
26
|
+
"handlers": ["default"],
|
|
27
|
+
"level": "INFO",
|
|
28
|
+
"propagate": False,
|
|
29
|
+
},
|
|
30
|
+
"uvicorn.error": {"level": "INFO"},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import duckdb
|
|
3
|
+
from typing import Literal, Optional
|
|
4
|
+
import io
|
|
5
|
+
from contextlib import redirect_stdout
|
|
6
|
+
from tabulate import tabulate
|
|
7
|
+
import logging
|
|
8
|
+
from .configs import SERVER_VERSION
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("mcp_server_motherduck")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabaseClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
db_path: str | None = None,
|
|
17
|
+
motherduck_token: str | None = None,
|
|
18
|
+
home_dir: str | None = None,
|
|
19
|
+
saas_mode: bool = False,
|
|
20
|
+
read_only: bool = False,
|
|
21
|
+
):
|
|
22
|
+
self._read_only = read_only
|
|
23
|
+
self.db_path, self.db_type = self._resolve_db_path_type(
|
|
24
|
+
db_path, motherduck_token, saas_mode
|
|
25
|
+
)
|
|
26
|
+
logger.info(f"Database client initialized in `{self.db_type}` mode")
|
|
27
|
+
|
|
28
|
+
# Set the home directory for DuckDB
|
|
29
|
+
if home_dir:
|
|
30
|
+
os.environ["HOME"] = home_dir
|
|
31
|
+
|
|
32
|
+
self.conn = self._initialize_connection()
|
|
33
|
+
|
|
34
|
+
def _initialize_connection(self) -> Optional[duckdb.DuckDBPyConnection]:
|
|
35
|
+
"""Initialize connection to the MotherDuck or DuckDB database"""
|
|
36
|
+
|
|
37
|
+
logger.info(f"🔌 Connecting to {self.db_type} database")
|
|
38
|
+
|
|
39
|
+
if self.db_type == "duckdb" and self._read_only:
|
|
40
|
+
# check that we can connect, issue a `select 1` and then close + return None
|
|
41
|
+
try:
|
|
42
|
+
conn = duckdb.connect(
|
|
43
|
+
self.db_path,
|
|
44
|
+
config={
|
|
45
|
+
"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"
|
|
46
|
+
},
|
|
47
|
+
read_only=self._read_only,
|
|
48
|
+
)
|
|
49
|
+
conn.execute("SELECT 1")
|
|
50
|
+
conn.close()
|
|
51
|
+
return None
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"❌ Read-only check failed: {e}")
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
if self._read_only:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"Read-only mode is only supported for local DuckDB databases. See `saas_mode` for similar functionality with MotherDuck."
|
|
59
|
+
)
|
|
60
|
+
conn = duckdb.connect(
|
|
61
|
+
self.db_path,
|
|
62
|
+
config={"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info(f"✅ Successfully connected to {self.db_type} database")
|
|
66
|
+
|
|
67
|
+
return conn
|
|
68
|
+
|
|
69
|
+
def _resolve_db_path_type(
|
|
70
|
+
self, db_path: str, motherduck_token: str | None = None, saas_mode: bool = False
|
|
71
|
+
) -> tuple[str, Literal["duckdb", "motherduck"]]:
|
|
72
|
+
"""Resolve and validate the database path"""
|
|
73
|
+
# Handle MotherDuck paths
|
|
74
|
+
if db_path.startswith("md:"):
|
|
75
|
+
if motherduck_token:
|
|
76
|
+
logger.info("Using MotherDuck token to connect to database `md:`")
|
|
77
|
+
if saas_mode:
|
|
78
|
+
logger.info("Connecting to MotherDuck in SaaS mode")
|
|
79
|
+
return (
|
|
80
|
+
f"{db_path}?motherduck_token={motherduck_token}&saas_mode=true",
|
|
81
|
+
"motherduck",
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
return (
|
|
85
|
+
f"{db_path}?motherduck_token={motherduck_token}",
|
|
86
|
+
"motherduck",
|
|
87
|
+
)
|
|
88
|
+
elif os.getenv("motherduck_token"):
|
|
89
|
+
logger.info(
|
|
90
|
+
"Using MotherDuck token from env to connect to database `md:`"
|
|
91
|
+
)
|
|
92
|
+
return (
|
|
93
|
+
f"{db_path}?motherduck_token={os.getenv('motherduck_token')}",
|
|
94
|
+
"motherduck",
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"Please set the `motherduck_token` as an environment variable or pass it as an argument with `--motherduck-token` when using `md:` as db_path."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if db_path == ":memory:":
|
|
102
|
+
return db_path, "duckdb"
|
|
103
|
+
|
|
104
|
+
return db_path, "duckdb"
|
|
105
|
+
|
|
106
|
+
def _execute(self, query: str) -> str:
|
|
107
|
+
if self.conn is None:
|
|
108
|
+
# open short lived readonly connection, run query, close connection, return result
|
|
109
|
+
conn = duckdb.connect(
|
|
110
|
+
self.db_path,
|
|
111
|
+
config={"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"},
|
|
112
|
+
read_only=self._read_only,
|
|
113
|
+
)
|
|
114
|
+
q = conn.execute(query)
|
|
115
|
+
else:
|
|
116
|
+
q = self.conn.execute(query)
|
|
117
|
+
|
|
118
|
+
out = tabulate(
|
|
119
|
+
q.fetchall(),
|
|
120
|
+
headers=[d[0] + "\n" + d[1] for d in q.description],
|
|
121
|
+
tablefmt="pretty",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if self.conn is None:
|
|
125
|
+
conn.close()
|
|
126
|
+
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def query(self, query: str) -> str:
|
|
130
|
+
try:
|
|
131
|
+
return self._execute(query)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise ValueError(f"❌ Error executing query: {e}")
|
mcp_server_motherduck/server.py
CHANGED
|
@@ -1,163 +1,20 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import logging
|
|
3
|
-
import duckdb
|
|
4
2
|
from pydantic import AnyUrl
|
|
5
|
-
from typing import Literal
|
|
6
|
-
import io
|
|
7
|
-
from contextlib import redirect_stdout
|
|
8
|
-
from tabulate import tabulate
|
|
9
|
-
import mcp.server.stdio
|
|
3
|
+
from typing import Literal
|
|
10
4
|
import mcp.types as types
|
|
11
5
|
from mcp.server import NotificationOptions, Server
|
|
12
6
|
from mcp.server.models import InitializationOptions
|
|
7
|
+
from .configs import SERVER_VERSION
|
|
8
|
+
from .database import DatabaseClient
|
|
13
9
|
from .prompt import PROMPT_TEMPLATE
|
|
14
10
|
|
|
15
11
|
|
|
16
|
-
SERVER_VERSION = "0.5.1"
|
|
17
|
-
|
|
18
12
|
logger = logging.getLogger("mcp_server_motherduck")
|
|
19
13
|
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
def __init__(
|
|
23
|
-
self,
|
|
24
|
-
db_path: str | None = None,
|
|
25
|
-
motherduck_token: str | None = None,
|
|
26
|
-
result_format: Literal["markdown", "duckbox", "text"] = "markdown",
|
|
27
|
-
home_dir: str | None = None,
|
|
28
|
-
saas_mode: bool = False,
|
|
29
|
-
read_only: bool = False,
|
|
30
|
-
):
|
|
31
|
-
self._read_only = read_only
|
|
32
|
-
self.db_path, self.db_type = self._resolve_db_path_type(
|
|
33
|
-
db_path, motherduck_token, saas_mode
|
|
34
|
-
)
|
|
35
|
-
logger.info(f"Database client initialized in `{self.db_type}` mode")
|
|
36
|
-
|
|
37
|
-
# Set the home directory for DuckDB
|
|
38
|
-
if home_dir:
|
|
39
|
-
os.environ["HOME"] = home_dir
|
|
40
|
-
|
|
41
|
-
self.conn = self._initialize_connection()
|
|
42
|
-
self.result_format = result_format
|
|
43
|
-
|
|
44
|
-
def _initialize_connection(self) -> Optional[duckdb.DuckDBPyConnection]:
|
|
45
|
-
"""Initialize connection to the MotherDuck or DuckDB database"""
|
|
46
|
-
|
|
47
|
-
logger.info(f"🔌 Connecting to {self.db_type} database")
|
|
48
|
-
|
|
49
|
-
if self.db_type == "duckdb" and self._read_only:
|
|
50
|
-
# check that we can connect, issue a `select 1` and then close + return None
|
|
51
|
-
try:
|
|
52
|
-
conn = duckdb.connect(
|
|
53
|
-
self.db_path,
|
|
54
|
-
config={
|
|
55
|
-
"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"
|
|
56
|
-
},
|
|
57
|
-
read_only=self._read_only,
|
|
58
|
-
)
|
|
59
|
-
conn.execute("SELECT 1")
|
|
60
|
-
conn.close()
|
|
61
|
-
return None
|
|
62
|
-
except Exception as e:
|
|
63
|
-
logger.error(f"❌ Read-only check failed: {e}")
|
|
64
|
-
raise
|
|
65
|
-
|
|
66
|
-
if self._read_only:
|
|
67
|
-
raise ValueError(
|
|
68
|
-
"Read-only mode is only supported for local DuckDB databases. See `saas_mode` for similar functionality with MotherDuck."
|
|
69
|
-
)
|
|
70
|
-
conn = duckdb.connect(
|
|
71
|
-
self.db_path,
|
|
72
|
-
config={"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"},
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
logger.info(f"✅ Successfully connected to {self.db_type} database")
|
|
76
|
-
|
|
77
|
-
return conn
|
|
78
|
-
|
|
79
|
-
def _resolve_db_path_type(
|
|
80
|
-
self, db_path: str, motherduck_token: str | None = None, saas_mode: bool = False
|
|
81
|
-
) -> tuple[str, Literal["duckdb", "motherduck"]]:
|
|
82
|
-
"""Resolve and validate the database path"""
|
|
83
|
-
# Handle MotherDuck paths
|
|
84
|
-
if db_path.startswith("md:"):
|
|
85
|
-
if motherduck_token:
|
|
86
|
-
logger.info("Using MotherDuck token to connect to database `md:`")
|
|
87
|
-
if saas_mode:
|
|
88
|
-
logger.info("Connecting to MotherDuck in SaaS mode")
|
|
89
|
-
return (
|
|
90
|
-
f"{db_path}?motherduck_token={motherduck_token}&saas_mode=true",
|
|
91
|
-
"motherduck",
|
|
92
|
-
)
|
|
93
|
-
else:
|
|
94
|
-
return (
|
|
95
|
-
f"{db_path}?motherduck_token={motherduck_token}",
|
|
96
|
-
"motherduck",
|
|
97
|
-
)
|
|
98
|
-
elif os.getenv("motherduck_token"):
|
|
99
|
-
logger.info(
|
|
100
|
-
"Using MotherDuck token from env to connect to database `md:`"
|
|
101
|
-
)
|
|
102
|
-
return (
|
|
103
|
-
f"{db_path}?motherduck_token={os.getenv('motherduck_token')}",
|
|
104
|
-
"motherduck",
|
|
105
|
-
)
|
|
106
|
-
else:
|
|
107
|
-
raise ValueError(
|
|
108
|
-
"Please set the `motherduck_token` as an environment variable or pass it as an argument with `--motherduck-token` when using `md:` as db_path."
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
if db_path == ":memory:":
|
|
112
|
-
return db_path, "duckdb"
|
|
113
|
-
|
|
114
|
-
# Handle local database paths as the last check
|
|
115
|
-
if not os.path.exists(db_path):
|
|
116
|
-
raise FileNotFoundError(
|
|
117
|
-
f"The local database path `{db_path}` does not exist."
|
|
118
|
-
)
|
|
119
|
-
return db_path, "duckdb"
|
|
120
|
-
|
|
121
|
-
def _execute(self, query: str) -> str:
|
|
122
|
-
if self.conn is None:
|
|
123
|
-
# open short lived readonly connection, run query, close connection, return result
|
|
124
|
-
conn = duckdb.connect(
|
|
125
|
-
self.db_path,
|
|
126
|
-
config={"custom_user_agent": f"mcp-server-motherduck/{SERVER_VERSION}"},
|
|
127
|
-
read_only=self._read_only,
|
|
128
|
-
)
|
|
129
|
-
q = conn.execute(query)
|
|
130
|
-
else:
|
|
131
|
-
q = self.conn.execute(query)
|
|
132
|
-
|
|
133
|
-
if self.result_format == "markdown":
|
|
134
|
-
out = tabulate(q.fetchall(), headers=[d[0]+'\n'+d[1] for d in q.description], tablefmt="pretty")
|
|
135
|
-
elif self.result_format == "duckbox":
|
|
136
|
-
# Duckbox version of the output
|
|
137
|
-
buffer = io.StringIO()
|
|
138
|
-
with redirect_stdout(buffer):
|
|
139
|
-
q.show(max_rows=100, max_col_width=20)
|
|
140
|
-
out = buffer.getvalue()
|
|
141
|
-
else:
|
|
142
|
-
out = str(q.fetchall())
|
|
143
|
-
|
|
144
|
-
if self.conn is None:
|
|
145
|
-
conn.close()
|
|
146
|
-
|
|
147
|
-
return out
|
|
148
|
-
|
|
149
|
-
def query(self, query: str) -> str:
|
|
150
|
-
try:
|
|
151
|
-
return self._execute(query)
|
|
152
|
-
|
|
153
|
-
except Exception as e:
|
|
154
|
-
raise ValueError(f"❌ Error executing query: {e}")
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
async def main(
|
|
15
|
+
def build_application(
|
|
158
16
|
db_path: str,
|
|
159
17
|
motherduck_token: str | None = None,
|
|
160
|
-
result_format: Literal["markdown", "duckbox", "text"] = "markdown",
|
|
161
18
|
home_dir: str | None = None,
|
|
162
19
|
saas_mode: bool = False,
|
|
163
20
|
read_only: bool = False,
|
|
@@ -166,7 +23,6 @@ async def main(
|
|
|
166
23
|
server = Server("mcp-server-motherduck")
|
|
167
24
|
db_client = DatabaseClient(
|
|
168
25
|
db_path=db_path,
|
|
169
|
-
result_format=result_format,
|
|
170
26
|
motherduck_token=motherduck_token,
|
|
171
27
|
home_dir=home_dir,
|
|
172
28
|
saas_mode=saas_mode,
|
|
@@ -281,20 +137,13 @@ async def main(
|
|
|
281
137
|
logger.error(f"Error executing tool {name}: {e}")
|
|
282
138
|
raise ValueError(f"Error executing tool {name}: {str(e)}")
|
|
283
139
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
capabilities=server.get_capabilities(
|
|
293
|
-
notification_options=NotificationOptions(),
|
|
294
|
-
experimental_capabilities={},
|
|
295
|
-
),
|
|
296
|
-
),
|
|
297
|
-
)
|
|
140
|
+
initialization_options = InitializationOptions(
|
|
141
|
+
server_name="motherduck",
|
|
142
|
+
server_version=SERVER_VERSION,
|
|
143
|
+
capabilities=server.get_capabilities(
|
|
144
|
+
notification_options=NotificationOptions(),
|
|
145
|
+
experimental_capabilities={},
|
|
146
|
+
),
|
|
147
|
+
)
|
|
298
148
|
|
|
299
|
-
|
|
300
|
-
logger.info("\n🦆 MotherDuck MCP Server shutting down...")
|
|
149
|
+
return server, initialization_options
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-server-motherduck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: A MCP server for MotherDuck and local DuckDB
|
|
5
5
|
Author-email: tdoehmen <till@motherduck.com>
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
|
-
Requires-Dist:
|
|
9
|
-
Requires-Dist:
|
|
8
|
+
Requires-Dist: anyio>=4.8.0
|
|
9
|
+
Requires-Dist: click>=8.1.8
|
|
10
|
+
Requires-Dist: duckdb==1.3.1
|
|
11
|
+
Requires-Dist: mcp>=1.9.4
|
|
12
|
+
Requires-Dist: starlette>=0.46.1
|
|
10
13
|
Requires-Dist: tabulate>=0.9.0
|
|
14
|
+
Requires-Dist: uvicorn>=0.34.0
|
|
11
15
|
Description-Content-Type: text/markdown
|
|
12
16
|
|
|
13
17
|
# MotherDuck's DuckDB MCP Server
|
|
14
18
|
|
|
15
19
|
An MCP server implementation that interacts with DuckDB and MotherDuck databases, providing SQL analytics capabilities to AI Assistants and IDEs.
|
|
16
20
|
|
|
21
|
+
[](https://cursor.com/install-mcp?name=DuckDB&config=eyJjb21tYW5kIjoidXZ4IG1jcC1zZXJ2ZXItbW90aGVyZHVjayAtLWRiLXBhdGggbWQ6IiwiZW52Ijp7Im1vdGhlcmR1Y2tfdG9rZW4iOiIifX0%3D)
|
|
22
|
+
|
|
17
23
|
## Resources
|
|
18
24
|
- [Close the Loop: Faster Data Pipelines with MCP, DuckDB & AI (Blogpost)](https://motherduck.com/blog/faster-data-pipelines-with-mcp-duckdb-ai/)
|
|
19
25
|
- [Faster Data Pipelines development with MCP and DuckDB (YouTube)](https://www.youtube.com/watch?v=yG1mv8ZRxcU)
|
|
@@ -44,6 +50,37 @@ The server offers one tool:
|
|
|
44
50
|
|
|
45
51
|
All interactions with both DuckDB and MotherDuck are done through writing SQL queries.
|
|
46
52
|
|
|
53
|
+
## Command Line Parameters
|
|
54
|
+
|
|
55
|
+
The MCP server supports the following parameters:
|
|
56
|
+
|
|
57
|
+
| Parameter | Type | Default | Description |
|
|
58
|
+
|-----------|------|---------|-------------|
|
|
59
|
+
| `--transport` | Choice | `stdio` | Transport type. Options: `stdio`, `sse`, `stream` |
|
|
60
|
+
| `--port` | Integer | `8000` | Port to listen on for sse and stream transport mode |
|
|
61
|
+
| `--db-path` | String | `md:` | Path to local DuckDB database file or MotherDuck database |
|
|
62
|
+
| `--motherduck-token` | String | `None` | Access token to use for MotherDuck database connections (uses `motherduck_token` env var by default) |
|
|
63
|
+
| `--read-only` | Flag | `False` | Flag for connecting to DuckDB in read-only mode. Only supported for local DuckDB databases. Uses short-lived connections for concurrent access |
|
|
64
|
+
| `--home-dir` | String | `None` | Home directory for DuckDB (uses `HOME` env var by default) |
|
|
65
|
+
| `--saas-mode` | Flag | `False` | Flag for connecting to MotherDuck in SaaS mode |
|
|
66
|
+
| `--json-response` | Flag | `False` | Enable JSON responses for HTTP stream. Only supported for `stream` transport |
|
|
67
|
+
|
|
68
|
+
### Quick Usage Examples
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Connect to local DuckDB file in read-only mode with stream transport mode
|
|
72
|
+
uvx mcp-server-motherduck --transport stream --db-path /path/to/local.db --read-only
|
|
73
|
+
|
|
74
|
+
# Connect to MotherDuck with token with stream transport mode
|
|
75
|
+
uvx mcp-server-motherduck --transport stream --db-path md: --motherduck-token YOUR_TOKEN
|
|
76
|
+
|
|
77
|
+
# Connect to local DuckDB file in read-only mode with stream transport mode
|
|
78
|
+
uvx mcp-server-motherduck --transport stream --db-path /path/to/local.db --read-only
|
|
79
|
+
|
|
80
|
+
# Connect to MotherDuck in SaaS mode for enhanced security with stream transport mode
|
|
81
|
+
uvx mcp-server-motherduck --transport stream --db-path md: --motherduck-token YOUR_TOKEN --saas-mode
|
|
82
|
+
```
|
|
83
|
+
|
|
47
84
|
## Getting Started
|
|
48
85
|
|
|
49
86
|
### General Prerequisites
|
|
@@ -93,11 +130,11 @@ See [Connect to local DuckDB](#connect-to-local-duckdb).
|
|
|
93
130
|
|
|
94
131
|
### Usage with VS Code
|
|
95
132
|
|
|
96
|
-
[](https://insiders.vscode.dev/redirect/mcp/install?name=mcp-server-motherduck&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-motherduck%22%2C%22--db-path%22%2C%22md%3A%22%2C%22--motherduck-token%22%2C%22%24%7Binput%3Amotherduck_token%7D%22%5D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22motherduck_token%22%2C%22description%22%3A%22MotherDuck+Token%22%2C%22password%22%3Atrue%7D%5D) [](https://insiders.vscode.dev/redirect/mcp/install?name=mcp-server-motherduck&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-motherduck%22%2C%22--db-path%22%2C%22md%3A%22%2C%22--motherduck-token%22%2C%22%24%7Binput%3Amotherduck_token%7D%22%5D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22motherduck_token%22%2C%22description%22%3A%22MotherDuck+Token%22%2C%22password%22%3Atrue%7D%5D&quality=insiders)
|
|
97
134
|
|
|
98
|
-
|
|
135
|
+
For the quickest installation, click one of the "Install with UV" buttons at the top.
|
|
99
136
|
|
|
100
|
-
|
|
137
|
+
#### Manual Installation
|
|
101
138
|
|
|
102
139
|
Add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
|
|
103
140
|
|
|
@@ -288,47 +325,29 @@ Once configured, you can e.g. ask Claude to run queries like:
|
|
|
288
325
|
- "Join data from my local DuckDB database with a table in MotherDuck"
|
|
289
326
|
- "Analyze data stored in Amazon S3"
|
|
290
327
|
|
|
291
|
-
##
|
|
292
|
-
|
|
293
|
-
The server is designed to be run by tools like Claude Desktop and Cursor, but you can start it manually for testing purposes. When testing the server manually, you can specify which database to connect to using the `--db-path` parameter:
|
|
294
|
-
|
|
295
|
-
1. **Default MotherDuck database**:
|
|
296
|
-
|
|
297
|
-
- To connect to the default MotherDuck database, you will need to pass the auth token using the `--motherduck-token` parameter.
|
|
298
|
-
|
|
299
|
-
```bash
|
|
300
|
-
uvx mcp-server-motherduck --db-path md: --motherduck-token <your_motherduck_token>
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
2. **Specific MotherDuck database**:
|
|
304
|
-
|
|
305
|
-
```bash
|
|
306
|
-
uvx mcp-server-motherduck --db-path md:your_database_name --motherduck-token <your_motherduck_token>
|
|
307
|
-
```
|
|
328
|
+
## Running in SSE mode
|
|
308
329
|
|
|
309
|
-
|
|
330
|
+
The server can run in SSE mode in two ways:
|
|
310
331
|
|
|
311
|
-
|
|
312
|
-
uvx mcp-server-motherduck --db-path /path/to/your/local.db
|
|
313
|
-
```
|
|
332
|
+
### Direct SSE mode
|
|
314
333
|
|
|
315
|
-
|
|
334
|
+
Run the server directly in SSE mode using the `--transport sse` flag:
|
|
316
335
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
336
|
+
```bash
|
|
337
|
+
uvx mcp-server-motherduck --transport sse --port 8000 --db-path md: --motherduck-token <your_motherduck_token>
|
|
338
|
+
```
|
|
320
339
|
|
|
321
|
-
|
|
340
|
+
This will start the server listening on the specified port (default 8000) and you can point your clients directly to this endpoint.
|
|
322
341
|
|
|
323
|
-
|
|
342
|
+
### Using supergateway
|
|
324
343
|
|
|
325
|
-
|
|
344
|
+
Alternatively, you can run SSE mode using `supergateway`:
|
|
326
345
|
|
|
327
346
|
```bash
|
|
328
347
|
npx -y supergateway --stdio "uvx mcp-server-motherduck --db-path md: --motherduck-token <your_motherduck_token>"
|
|
329
348
|
```
|
|
330
349
|
|
|
331
|
-
|
|
350
|
+
Both methods allow you to point your clients such as Claude Desktop, Cursor to the SSE endpoint.
|
|
332
351
|
|
|
333
352
|
## Development configuration
|
|
334
353
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mcp_server_motherduck/__init__.py,sha256=vogjO-bwHYZFut85eeiVmRepamcWHk8bImri_kBKZMU,6026
|
|
2
|
+
mcp_server_motherduck/configs.py,sha256=751VWhE8qm71zb10gPEMYmuN5C2HCH_MKaOAR6y4x3g,771
|
|
3
|
+
mcp_server_motherduck/database.py,sha256=yx07lz_vpCzuYflkQ1FXJMA9gRjDkgg0hFbdmZx2s_s,4684
|
|
4
|
+
mcp_server_motherduck/prompt.py,sha256=P7BrmhVXwDkPeSHQ3f25WMP6lpBpN2BxDzYPOQ3fxX8,56699
|
|
5
|
+
mcp_server_motherduck/server.py,sha256=U1LM2oQ36gO_pAZuez9HV_u8YDWdER8tQIdDbiXfzx0,5232
|
|
6
|
+
mcp_server_motherduck-0.6.1.dist-info/METADATA,sha256=fljNXrkiegDXZKW1H7OK9rnIzwQ3h1jRS4vjim-O8ZI,13718
|
|
7
|
+
mcp_server_motherduck-0.6.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
mcp_server_motherduck-0.6.1.dist-info/entry_points.txt,sha256=dRTgcvWJn40bz0PVuKPylK6w92cFN32lwunZOgo5j4s,69
|
|
9
|
+
mcp_server_motherduck-0.6.1.dist-info/licenses/LICENSE,sha256=Tj68w9jCiceFKTvZ3jET-008NjhozcQMXpm-fyL9WUI,1067
|
|
10
|
+
mcp_server_motherduck-0.6.1.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
mcp_server_motherduck/__init__.py,sha256=Yd_ron4Fy7mV_p48z0IFNKNbqC7fYfjNnPv4pJ6dRCg,2208
|
|
2
|
-
mcp_server_motherduck/prompt.py,sha256=P7BrmhVXwDkPeSHQ3f25WMP6lpBpN2BxDzYPOQ3fxX8,56699
|
|
3
|
-
mcp_server_motherduck/server.py,sha256=jkMN5gxs7NW5Nk5Drt6OxJ3wY7TRTkUNuAd-hNf0oXI,10857
|
|
4
|
-
mcp_server_motherduck-0.5.1.dist-info/METADATA,sha256=a3lpQCcqKON01lFpH4d7zvsM-cPASlXE6rIzwVL7vH8,12327
|
|
5
|
-
mcp_server_motherduck-0.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
-
mcp_server_motherduck-0.5.1.dist-info/entry_points.txt,sha256=dRTgcvWJn40bz0PVuKPylK6w92cFN32lwunZOgo5j4s,69
|
|
7
|
-
mcp_server_motherduck-0.5.1.dist-info/licenses/LICENSE,sha256=Tj68w9jCiceFKTvZ3jET-008NjhozcQMXpm-fyL9WUI,1067
|
|
8
|
-
mcp_server_motherduck-0.5.1.dist-info/RECORD,,
|
|
File without changes
|
{mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{mcp_server_motherduck-0.5.1.dist-info → mcp_server_motherduck-0.6.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|