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,149 +1,242 @@
1
+ """
2
+ FastMCP Server for MotherDuck and DuckDB.
3
+
4
+ This module creates and configures the FastMCP server with all tools.
5
+ """
6
+
7
+ import json
1
8
  import logging
2
- from pydantic import AnyUrl
3
- from typing import Literal
4
- import mcp.types as types
5
- from mcp.server import NotificationOptions, Server
6
- from mcp.server.models import InitializationOptions
9
+ from pathlib import Path
10
+
11
+ from fastmcp import FastMCP
12
+ from fastmcp.utilities.types import Image
13
+ from mcp.types import Icon
14
+
7
15
  from .configs import SERVER_VERSION
8
16
  from .database import DatabaseClient
9
- from .prompt import PROMPT_TEMPLATE
10
-
17
+ from .instructions import get_instructions
18
+ from .tools.execute_query import execute_query as execute_query_fn
19
+ from .tools.list_columns import list_columns as list_columns_fn
20
+ from .tools.list_databases import list_databases as list_databases_fn
21
+ from .tools.list_tables import list_tables as list_tables_fn
22
+ from .tools.switch_database_connection import (
23
+ switch_database_connection as switch_database_connection_fn,
24
+ )
11
25
 
12
26
  logger = logging.getLogger("mcp_server_motherduck")
13
27
 
28
+ # Server icon - embedded as data URI from local file
29
+ ASSETS_DIR = Path(__file__).parent / "assets"
30
+ ICON_PATH = ASSETS_DIR / "duck_feet_square.png"
31
+
14
32
 
15
- def build_application(
33
+ def create_mcp_server(
16
34
  db_path: str,
17
35
  motherduck_token: str | None = None,
18
36
  home_dir: str | None = None,
19
37
  saas_mode: bool = False,
20
38
  read_only: bool = False,
21
- ):
22
- logger.info("Starting MotherDuck MCP Server")
23
- server = Server("mcp-server-motherduck")
39
+ ephemeral_connections: bool = True,
40
+ max_rows: int = 1024,
41
+ max_chars: int = 50000,
42
+ query_timeout: int = -1,
43
+ init_sql: str | None = None,
44
+ allow_switch_databases: bool = False,
45
+ ) -> FastMCP:
46
+ """
47
+ Create and configure the FastMCP server.
48
+
49
+ Args:
50
+ db_path: Path to database (local file, :memory:, md:, or s3://)
51
+ motherduck_token: MotherDuck authentication token
52
+ home_dir: Home directory for DuckDB
53
+ saas_mode: Enable MotherDuck SaaS mode
54
+ read_only: Enable read-only mode
55
+ ephemeral_connections: Use temporary connections for read-only local files
56
+ max_rows: Maximum rows to return from queries
57
+ max_chars: Maximum characters in query results
58
+ query_timeout: Query timeout in seconds (-1 to disable)
59
+ init_sql: SQL file path or string to execute on startup
60
+ allow_switch_databases: Enable the switch_database_connection tool
61
+
62
+ Returns:
63
+ Configured FastMCP server instance
64
+ """
65
+ # Create database client
24
66
  db_client = DatabaseClient(
25
67
  db_path=db_path,
26
68
  motherduck_token=motherduck_token,
27
69
  home_dir=home_dir,
28
70
  saas_mode=saas_mode,
29
71
  read_only=read_only,
72
+ ephemeral_connections=ephemeral_connections,
73
+ max_rows=max_rows,
74
+ max_chars=max_chars,
75
+ query_timeout=query_timeout,
76
+ init_sql=init_sql,
30
77
  )
31
78
 
32
- logger.info("Registering handlers")
79
+ # Get instructions with connection context
80
+ instructions = get_instructions(
81
+ read_only=read_only,
82
+ saas_mode=saas_mode,
83
+ db_path=db_path,
84
+ allow_switch_databases=allow_switch_databases,
85
+ )
33
86
 
34
- @server.list_resources()
35
- async def handle_list_resources() -> list[types.Resource]:
36
- """
37
- List available note resources.
38
- Each note is exposed as a resource with a custom note:// URI scheme.
39
- """
40
- logger.info("No resources available to list")
41
- return []
87
+ # Create server icon from local file
88
+ icons = []
89
+ if ICON_PATH.exists():
90
+ img = Image(path=str(ICON_PATH))
91
+ icons.append(Icon(src=img.to_data_uri(), mimeType="image/png"))
92
+
93
+ # Create FastMCP server with icon
94
+ mcp = FastMCP(
95
+ name="mcp-server-motherduck",
96
+ instructions=instructions,
97
+ version=SERVER_VERSION,
98
+ icons=icons if icons else None,
99
+ )
42
100
 
43
- @server.read_resource()
44
- async def handle_read_resource(uri: AnyUrl) -> str:
45
- """
46
- Read a specific note's content by its URI.
47
- The note name is extracted from the URI host component.
101
+ # Define query tool annotations (dynamic based on read_only flag)
102
+ query_annotations = {
103
+ "readOnlyHint": read_only,
104
+ "destructiveHint": not read_only,
105
+ "openWorldHint": False,
106
+ }
107
+
108
+ # Catalog tool annotations (always read-only)
109
+ catalog_annotations = {
110
+ "readOnlyHint": True,
111
+ "destructiveHint": False,
112
+ "openWorldHint": False,
113
+ }
114
+
115
+ # Switch database annotations (open world - can connect to any database)
116
+ switch_db_annotations = {
117
+ "readOnlyHint": False,
118
+ "destructiveHint": False,
119
+ "openWorldHint": True,
120
+ }
121
+
122
+ # Register query tool
123
+ @mcp.tool(
124
+ name="execute_query",
125
+ title="Execute Query",
126
+ description="Execute a SQL query on the DuckDB or MotherDuck database. Unqualified table names resolve to current_database() and current_schema() automatically. Fully qualified names (database.schema.table) are only needed when multiple DuckDB databases are attached or when connected to MotherDuck.",
127
+ annotations=query_annotations,
128
+ )
129
+ def execute_query(sql: str) -> str:
48
130
  """
49
- logger.info(f"Reading resource: {uri}")
50
- raise ValueError(f"Unsupported URI scheme: {uri.scheme}")
131
+ Execute a SQL query on the DuckDB or MotherDuck database.
51
132
 
52
- @server.list_prompts()
53
- async def handle_list_prompts() -> list[types.Prompt]:
133
+ Args:
134
+ sql: SQL query to execute (DuckDB SQL dialect)
135
+
136
+ Returns:
137
+ JSON string with query results
138
+
139
+ Raises:
140
+ ValueError: If the query fails
54
141
  """
55
- List available prompts.
56
- Each prompt can have optional arguments to customize its behavior.
142
+ result = execute_query_fn(sql, db_client)
143
+ if not result.get("success", True):
144
+ # Raise exception so FastMCP marks as isError=True
145
+ raise ValueError(json.dumps(result, indent=2, default=str))
146
+ return json.dumps(result, indent=2, default=str)
147
+
148
+ # Register list_databases tool
149
+ @mcp.tool(
150
+ name="list_databases",
151
+ title="List Databases",
152
+ description="List all databases available in the connection. Useful when multiple DuckDB databases are attached or when connected to MotherDuck.",
153
+ annotations=catalog_annotations,
154
+ )
155
+ def list_databases_tool() -> str:
57
156
  """
58
- logger.info("Listing prompts")
59
- # TODO: Check where and how this is used, and how to optimize this.
60
- # Check postgres and sqlite servers.
61
- return [
62
- types.Prompt(
63
- name="duckdb-motherduck-initial-prompt",
64
- description="A prompt to initialize a connection to duckdb or motherduck and start working with it",
65
- )
66
- ]
157
+ List all databases available in the connection.
67
158
 
68
- @server.get_prompt()
69
- async def handle_get_prompt(
70
- name: str, arguments: dict[str, str] | None
71
- ) -> types.GetPromptResult:
159
+ Returns:
160
+ JSON string with database list
72
161
  """
73
- Generate a prompt by combining arguments with server state.
74
- The prompt includes all current notes and can be customized via arguments.
162
+ result = list_databases_fn(db_client)
163
+ return json.dumps(result, indent=2, default=str)
164
+
165
+ # Register list_tables tool
166
+ @mcp.tool(
167
+ name="list_tables",
168
+ title="List Tables",
169
+ description="List all tables and views in a database with their comments. If database is not specified, uses the current database.",
170
+ annotations=catalog_annotations,
171
+ )
172
+ def list_tables(database: str | None = None, schema: str | None = None) -> str:
75
173
  """
76
- logger.info(f"Getting prompt: {name}::{arguments}")
77
- # TODO: Check where and how this is used, and how to optimize this.
78
- # Check postgres and sqlite servers.
79
- if name != "duckdb-motherduck-initial-prompt":
80
- raise ValueError(f"Unknown prompt: {name}")
81
-
82
- return types.GetPromptResult(
83
- description="Initial prompt for interacting with DuckDB/MotherDuck",
84
- messages=[
85
- types.PromptMessage(
86
- role="user",
87
- content=types.TextContent(type="text", text=PROMPT_TEMPLATE),
88
- )
89
- ],
90
- )
174
+ List all tables and views in a database.
91
175
 
92
- @server.list_tools()
93
- async def handle_list_tools() -> list[types.Tool]:
94
- """
95
- List available tools.
96
- Each tool specifies its arguments using JSON Schema validation.
176
+ Args:
177
+ database: Database name to list tables from (defaults to current database)
178
+ schema: Optional schema name to filter by
179
+
180
+ Returns:
181
+ JSON string with table/view list
97
182
  """
98
- logger.info("Listing tools")
99
- return [
100
- types.Tool(
101
- name="query",
102
- description="Use this to execute a query on the MotherDuck or DuckDB database",
103
- inputSchema={
104
- "type": "object",
105
- "properties": {
106
- "query": {
107
- "type": "string",
108
- "description": "SQL query to execute that is a dialect of DuckDB SQL",
109
- },
110
- },
111
- "required": ["query"],
112
- },
113
- ),
114
- ]
115
-
116
- @server.call_tool()
117
- async def handle_tool_call(
118
- name: str, arguments: dict | None
119
- ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
183
+ result = list_tables_fn(db_client, database, schema)
184
+ return json.dumps(result, indent=2, default=str)
185
+
186
+ # Register list_columns tool
187
+ @mcp.tool(
188
+ name="list_columns",
189
+ title="List Columns",
190
+ description="List all columns of a table or view with their types and comments. If database/schema are not specified, uses the current database/schema.",
191
+ annotations=catalog_annotations,
192
+ )
193
+ def list_columns(table: str, database: str | None = None, schema: str | None = None) -> str:
120
194
  """
121
- Handle tool execution requests.
122
- Tools can modify server state and notify clients of changes.
195
+ List all columns of a table or view.
196
+
197
+ Args:
198
+ table: Table or view name
199
+ database: Database name (defaults to current database)
200
+ schema: Schema name (defaults to current schema)
201
+
202
+ Returns:
203
+ JSON string with column list
123
204
  """
124
- logger.info(f"Calling tool: {name}::{arguments}")
125
- try:
126
- if name == "query":
127
- if arguments is None:
128
- return [
129
- types.TextContent(type="text", text="Error: No query provided")
130
- ]
131
- tool_response = db_client.query(arguments["query"])
132
- return [types.TextContent(type="text", text=str(tool_response))]
133
-
134
- return [types.TextContent(type="text", text=f"Unsupported tool: {name}")]
135
-
136
- except Exception as e:
137
- logger.error(f"Error executing tool {name}: {e}")
138
- raise ValueError(f"Error executing tool {name}: {str(e)}")
139
-
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
- )
205
+ result = list_columns_fn(table, db_client, database, schema)
206
+ return json.dumps(result, indent=2, default=str)
207
+
208
+ # Conditionally register switch_database_connection tool
209
+ if allow_switch_databases:
210
+ # Store server's read_only setting for switch_database_connection
211
+ server_read_only_mode = read_only
212
+
213
+ @mcp.tool(
214
+ name="switch_database_connection",
215
+ title="Switch Database Connection",
216
+ description="Switch to a different database connection. For local files, use absolute paths only. The new connection respects the server's read-only/read-write mode. For local files, the file must exist unless create_if_not_exists=True (requires read-write mode).",
217
+ annotations=switch_db_annotations,
218
+ )
219
+ def switch_database_connection(path: str, create_if_not_exists: bool = False) -> str:
220
+ """
221
+ Switch to a different primary database.
222
+
223
+ Args:
224
+ path: Database path. For local files, must be an absolute path.
225
+ Also accepts :memory:, md:database_name, or s3:// paths.
226
+ create_if_not_exists: If True, create the database file if it doesn't exist.
227
+ Only works in read-write mode.
228
+
229
+ Returns:
230
+ JSON string with result
231
+ """
232
+ result = switch_database_connection_fn(
233
+ path=path,
234
+ db_client=db_client,
235
+ server_read_only=server_read_only_mode,
236
+ create_if_not_exists=create_if_not_exists,
237
+ )
238
+ return json.dumps(result, indent=2, default=str)
239
+
240
+ logger.info(f"FastMCP server created with {len(mcp._tool_manager._tools)} tools")
148
241
 
149
- return server, initialization_options
242
+ return mcp
@@ -0,0 +1,19 @@
1
+ """
2
+ MCP Tools for MotherDuck/DuckDB server.
3
+
4
+ Each tool is defined in its own module and exported here.
5
+ """
6
+
7
+ from .execute_query import execute_query
8
+ from .list_columns import list_columns
9
+ from .list_databases import list_databases
10
+ from .list_tables import list_tables
11
+ from .switch_database_connection import switch_database_connection
12
+
13
+ __all__ = [
14
+ "execute_query",
15
+ "list_databases",
16
+ "list_tables",
17
+ "list_columns",
18
+ "switch_database_connection",
19
+ ]
@@ -0,0 +1,21 @@
1
+ """
2
+ Execute Query tool - Execute SQL queries against DuckDB/MotherDuck databases.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ DESCRIPTION = "Execute a SQL query on the DuckDB or MotherDuck database."
8
+
9
+
10
+ def execute_query(sql: str, db_client: Any) -> dict[str, Any]:
11
+ """
12
+ Execute a SQL query on the DuckDB or MotherDuck database.
13
+
14
+ Args:
15
+ sql: SQL query to execute (DuckDB SQL dialect)
16
+ db_client: DatabaseClient instance (injected by server)
17
+
18
+ Returns:
19
+ JSON-serializable dict with query results or error
20
+ """
21
+ return db_client.query(sql)
@@ -0,0 +1,99 @@
1
+ """
2
+ List columns tool - List all columns of a table or view.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ DESCRIPTION = (
8
+ "List all columns of a table or view with their types and comments. "
9
+ "If database/schema are not specified, uses the current database/schema."
10
+ )
11
+
12
+
13
+ def list_columns(
14
+ table: str,
15
+ db_client: Any,
16
+ database: str | None = None,
17
+ schema: str | None = None,
18
+ ) -> dict[str, Any]:
19
+ """
20
+ List all columns of a table or view.
21
+
22
+ Args:
23
+ table: Table or view name
24
+ db_client: DatabaseClient instance (injected by server)
25
+ database: Database name (defaults to current database)
26
+ schema: Schema name (defaults to current schema)
27
+
28
+ Returns:
29
+ JSON-serializable dict with column list or error
30
+ """
31
+ try:
32
+ # Get current database if not specified
33
+ if database is None:
34
+ _, _, db_rows = db_client.execute_raw("SELECT current_database()")
35
+ database = db_rows[0][0]
36
+
37
+ # Get current schema if not specified
38
+ if schema is None:
39
+ _, _, schema_rows = db_client.execute_raw("SELECT current_schema()")
40
+ schema = schema_rows[0][0]
41
+
42
+ # Query columns using DuckDB system function
43
+ sql = f"""
44
+ SELECT
45
+ column_name as name,
46
+ data_type as type,
47
+ is_nullable = 'YES' as nullable,
48
+ comment
49
+ FROM duckdb_columns()
50
+ WHERE database_name = '{database}'
51
+ AND schema_name = '{schema}'
52
+ AND table_name = '{table}'
53
+ ORDER BY column_index
54
+ """
55
+
56
+ _, _, rows = db_client.execute_raw(sql)
57
+
58
+ # Transform results
59
+ columns = [
60
+ {
61
+ "name": row[0],
62
+ "type": row[1],
63
+ "nullable": bool(row[2]),
64
+ "comment": row[3] if row[3] else None,
65
+ }
66
+ for row in rows
67
+ ]
68
+
69
+ # Determine if it's a view or table
70
+ object_type = "table"
71
+ try:
72
+ _, _, view_rows = db_client.execute_raw(f"""
73
+ SELECT 1 FROM duckdb_views()
74
+ WHERE database_name = '{database}'
75
+ AND schema_name = '{schema}'
76
+ AND view_name = '{table}'
77
+ LIMIT 1
78
+ """)
79
+ if view_rows:
80
+ object_type = "view"
81
+ except Exception:
82
+ pass # Assume table if check fails
83
+
84
+ return {
85
+ "success": True,
86
+ "database": database,
87
+ "schema": schema,
88
+ "table": table,
89
+ "objectType": object_type,
90
+ "columns": columns,
91
+ "columnCount": len(columns),
92
+ }
93
+
94
+ except Exception as e:
95
+ return {
96
+ "success": False,
97
+ "error": str(e),
98
+ "errorType": type(e).__name__,
99
+ }
@@ -0,0 +1,52 @@
1
+ """
2
+ List databases tool - Show all databases available in the connection.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ DESCRIPTION = "List all databases with their names and types."
8
+
9
+
10
+ def list_databases(db_client: Any) -> dict[str, Any]:
11
+ """
12
+ List all databases available in the connection.
13
+
14
+ For MotherDuck: Uses MD_ALL_DATABASES() to list all databases.
15
+ For local DuckDB: Uses duckdb_databases() system function.
16
+
17
+ Excludes internal databases: 'system' and 'temp'.
18
+
19
+ Args:
20
+ db_client: DatabaseClient instance (injected by server)
21
+
22
+ Returns:
23
+ JSON-serializable dict with database list or error
24
+ """
25
+ try:
26
+ # Try MotherDuck function first (works for MotherDuck connections)
27
+ try:
28
+ _, _, rows = db_client.execute_raw(
29
+ "SELECT alias, type FROM MD_ALL_DATABASES() "
30
+ "WHERE alias IS NOT NULL AND alias NOT IN ('system', 'temp')"
31
+ )
32
+ databases = [{"name": row[0], "type": row[1]} for row in rows]
33
+ except Exception:
34
+ # Fall back to DuckDB system function (works for local DuckDB)
35
+ _, _, rows = db_client.execute_raw(
36
+ "SELECT database_name, type FROM duckdb_databases() "
37
+ "WHERE database_name NOT IN ('system', 'temp')"
38
+ )
39
+ databases = [{"name": row[0], "type": row[1]} for row in rows]
40
+
41
+ return {
42
+ "success": True,
43
+ "databases": databases,
44
+ "databaseCount": len(databases),
45
+ }
46
+
47
+ except Exception as e:
48
+ return {
49
+ "success": False,
50
+ "error": str(e),
51
+ "errorType": type(e).__name__,
52
+ }
@@ -0,0 +1,91 @@
1
+ """
2
+ List tables tool - List all tables and views in a database.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ DESCRIPTION = (
8
+ "List all tables and views in a database with their comments. "
9
+ "If database is not specified, uses the current database."
10
+ )
11
+
12
+
13
+ def list_tables(
14
+ db_client: Any,
15
+ database: str | None = None,
16
+ schema: str | None = None,
17
+ ) -> dict[str, Any]:
18
+ """
19
+ List all tables and views in a database.
20
+
21
+ Args:
22
+ db_client: DatabaseClient instance (injected by server)
23
+ database: Database name to list tables from (defaults to current database)
24
+ schema: Optional schema name to filter by (defaults to all schemas)
25
+
26
+ Returns:
27
+ JSON-serializable dict with table/view list or error
28
+ """
29
+ try:
30
+ # Get current database if not specified
31
+ if database is None:
32
+ _, _, db_rows = db_client.execute_raw("SELECT current_database()")
33
+ database = db_rows[0][0]
34
+
35
+ # Build schema filter
36
+ schema_filter = f"AND schema_name = '{schema}'" if schema else ""
37
+
38
+ # Query tables and views using DuckDB system functions
39
+ sql = f"""
40
+ SELECT
41
+ schema_name as schema,
42
+ table_name as name,
43
+ 'table' as type,
44
+ comment
45
+ FROM duckdb_tables()
46
+ WHERE database_name = '{database}' {schema_filter}
47
+
48
+ UNION ALL
49
+
50
+ SELECT
51
+ schema_name as schema,
52
+ view_name as name,
53
+ 'view' as type,
54
+ comment
55
+ FROM duckdb_views()
56
+ WHERE database_name = '{database}' {schema_filter}
57
+
58
+ ORDER BY schema, type, name
59
+ """
60
+
61
+ _, _, rows = db_client.execute_raw(sql)
62
+
63
+ # Transform results
64
+ tables = [
65
+ {
66
+ "schema": row[0],
67
+ "name": row[1],
68
+ "type": row[2],
69
+ "comment": row[3] if row[3] else None,
70
+ }
71
+ for row in rows
72
+ ]
73
+
74
+ table_count = sum(1 for t in tables if t["type"] == "table")
75
+ view_count = sum(1 for t in tables if t["type"] == "view")
76
+
77
+ return {
78
+ "success": True,
79
+ "database": database,
80
+ "schema": schema or "all",
81
+ "tables": tables,
82
+ "tableCount": table_count,
83
+ "viewCount": view_count,
84
+ }
85
+
86
+ except Exception as e:
87
+ return {
88
+ "success": False,
89
+ "error": str(e),
90
+ "errorType": type(e).__name__,
91
+ }