Skip to content

Commit cf15a4a

Browse files
authored
refactor (MCP): refactor MCP tools; share AI Assistant capabilities (baserow#4980)
* feat(mcp): refactor tools to service layer and add database/table/field CRUD Replace internal API request pattern with a dedicated service module for MCP operations. Simplify tool architecture from dynamic per-table tools to static tools with explicit table_id parameters. Add new tools for managing databases, tables, fields, and batch row operations. * fix(mcp): address Copilot review feedback - Let exceptions propagate from MCPTool.call() so call_tool() logs them - Fix N+1 queries in get_table_schema using enhance_field_queryset hook - Fetch fields for all tables in a single specific_iterator pass - Clamp list_rows pagination to safe bounds (ROW_PAGE_SIZE_LIMIT) - Fix update_fields docstring to reflect type-change support - Fix module path in utils.py comment - Update "Adding a new tool" docs to match current MCPTool pattern * fix: comment risky tools; waiting for a proper permission check * feat(mcp): add enabled flag to MCPTool and disable risky tools Add `enabled` attribute to MCPTool base class so tools can be registered but hidden from MCP clients. Disable database/table/field CRUD tools until users can control tool availability through the UI. Also add docs/testing/mcp-test-plan.md with manual testing instructions. * fix(mcp): replace list[dict] with typed Pydantic models for field/row specs Validate required keys (name+type for field create, id for field update, id for row update) at the schema level instead of relying on KeyError from dict.pop() in the service layer. Uses extra="allow" so type-specific options and field values pass through. * fix: wrap update_rows in a transaction * address feedback
1 parent 40c9f48 commit cf15a4a

24 files changed

Lines changed: 2110 additions & 1432 deletions

File tree

backend/src/baserow/contrib/database/apps.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,20 +1037,44 @@ def ready(self):
10371037
notification_type_registry.register(WebhookDeactivatedNotificationType())
10381038
notification_type_registry.register(WebhookPayloadTooLargeNotificationType())
10391039

1040+
from baserow.contrib.database.mcp.fields.tools import (
1041+
CreateFieldsMcpTool,
1042+
DeleteFieldsMcpTool,
1043+
UpdateFieldsMcpTool,
1044+
)
10401045
from baserow.contrib.database.mcp.rows.tools import (
1041-
CreateRowMcpTool,
1042-
DeleteRowMcpTool,
1046+
CreateRowsMcpTool,
1047+
DeleteRowsMcpTool,
10431048
ListRowsMcpTool,
1044-
UpdateRowMcpTool,
1049+
UpdateRowsMcpTool,
1050+
)
1051+
from baserow.contrib.database.mcp.table.tools import (
1052+
CreateDatabaseMcpTool,
1053+
CreateTableMcpTool,
1054+
DeleteTableMcpTool,
1055+
GetTableSchemaMcpTool,
1056+
ListDatabasesMcpTool,
1057+
ListTablesMcpTool,
1058+
UpdateTableMcpTool,
10451059
)
1046-
from baserow.contrib.database.mcp.table.tools import ListTablesMcpTool
10471060
from baserow.core.mcp.registries import mcp_tool_registry
10481061

1062+
mcp_tool_registry.register(ListDatabasesMcpTool())
10491063
mcp_tool_registry.register(ListTablesMcpTool())
1064+
mcp_tool_registry.register(GetTableSchemaMcpTool())
10501065
mcp_tool_registry.register(ListRowsMcpTool())
1051-
mcp_tool_registry.register(CreateRowMcpTool())
1052-
mcp_tool_registry.register(UpdateRowMcpTool())
1053-
mcp_tool_registry.register(DeleteRowMcpTool())
1066+
mcp_tool_registry.register(CreateRowsMcpTool())
1067+
mcp_tool_registry.register(UpdateRowsMcpTool())
1068+
mcp_tool_registry.register(DeleteRowsMcpTool())
1069+
# Disabled (enabled=False) until users can control tool
1070+
# availability through the UI.
1071+
mcp_tool_registry.register(CreateDatabaseMcpTool())
1072+
mcp_tool_registry.register(CreateTableMcpTool())
1073+
mcp_tool_registry.register(UpdateTableMcpTool())
1074+
mcp_tool_registry.register(DeleteTableMcpTool())
1075+
mcp_tool_registry.register(CreateFieldsMcpTool())
1076+
mcp_tool_registry.register(UpdateFieldsMcpTool())
1077+
mcp_tool_registry.register(DeleteFieldsMcpTool())
10541078

10551079
from baserow.contrib.database.rows.history_providers import (
10561080
CreateRowHistoryProvider,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, ConfigDict, Field
4+
5+
6+
class FieldSpec(BaseModel):
7+
"""A field to create: name and type are required, extra keys are type-specific."""
8+
9+
model_config = ConfigDict(extra="allow")
10+
11+
name: str = Field(..., description="The field name.")
12+
type: str = Field(..., description="The field type (e.g. text, number, link_row).")
13+
14+
15+
class FieldUpdateSpec(BaseModel):
16+
"""A field to update: id is required, extra keys are the properties to change."""
17+
18+
model_config = ConfigDict(extra="allow")
19+
20+
id: int = Field(..., description="The ID of the field to update.")
21+
22+
23+
class CreateFieldsInput(BaseModel):
24+
table_id: int = Field(..., description="The ID of the table to add fields to.")
25+
fields: list[FieldSpec] = Field(
26+
...,
27+
description=(
28+
"List of fields to create. Each item must have 'name' "
29+
"and 'type'. See create_table for valid types and extras."
30+
),
31+
)
32+
33+
34+
class UpdateFieldsInput(BaseModel):
35+
fields: list[FieldUpdateSpec] = Field(
36+
...,
37+
description=(
38+
"List of field updates. Each item must have 'id' "
39+
"plus the properties to change (name, type, "
40+
"or type-specific options)."
41+
),
42+
)
43+
44+
45+
class DeleteFieldsInput(BaseModel):
46+
field_ids: list[int] = Field(..., description="List of field IDs to delete.")
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from baserow.contrib.database.mcp import services
2+
from baserow.contrib.database.mcp.fields.schemas import (
3+
CreateFieldsInput,
4+
DeleteFieldsInput,
5+
UpdateFieldsInput,
6+
)
7+
from baserow.core.mcp.models import MCPEndpoint
8+
from baserow.core.mcp.registries import MCPTool
9+
10+
11+
class CreateFieldsMcpTool(MCPTool):
12+
"""
13+
Add one or more fields to an existing table.
14+
Call get_table_schema first to see existing fields and avoid duplicates.
15+
Fields are created in dependency order (regular → link_row → lookup → formula).
16+
For link_row fields, the linked table must already exist.
17+
"""
18+
19+
type = "create_fields"
20+
enabled = False
21+
input_schema = CreateFieldsInput
22+
23+
def _sync_call(self, endpoint: MCPEndpoint, args: CreateFieldsInput) -> list[dict]:
24+
return services.create_fields(
25+
endpoint.user,
26+
endpoint.workspace,
27+
args.table_id,
28+
[f.model_dump() for f in args.fields],
29+
)
30+
31+
32+
class UpdateFieldsMcpTool(MCPTool):
33+
"""
34+
Update one or more existing fields (rename, change type, change properties).
35+
Call get_table_schema first to get field IDs and current types.
36+
"""
37+
38+
type = "update_fields"
39+
enabled = False
40+
input_schema = UpdateFieldsInput
41+
42+
def _sync_call(self, endpoint: MCPEndpoint, args: UpdateFieldsInput) -> list[dict]:
43+
return services.update_fields(
44+
endpoint.user, endpoint.workspace, [f.model_dump() for f in args.fields]
45+
)
46+
47+
48+
class DeleteFieldsMcpTool(MCPTool):
49+
"""
50+
Delete (trash) one or more fields by ID.
51+
Primary fields cannot be deleted.
52+
Call get_table_schema first to confirm field IDs.
53+
"""
54+
55+
type = "delete_fields"
56+
enabled = False
57+
input_schema = DeleteFieldsInput
58+
59+
def _sync_call(self, endpoint: MCPEndpoint, args: DeleteFieldsInput) -> str:
60+
services.delete_fields(endpoint.user, endpoint.workspace, args.field_ids)
61+
return "Fields successfully deleted."
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, ConfigDict, Field
4+
5+
6+
class ListRowsInput(BaseModel):
7+
table_id: int = Field(..., description="The ID of the table to list rows from.")
8+
search: str | None = Field(None, description="Optional search term to filter rows.")
9+
page: int = Field(1, description="Page number (1-based).")
10+
size: int = Field(100, description="Maximum number of rows to return.")
11+
12+
13+
class CreateRowsInput(BaseModel):
14+
table_id: int = Field(..., description="The ID of the table to create rows in.")
15+
rows: list[dict] = Field(
16+
...,
17+
description=(
18+
"List of rows to create. Each row is an object mapping field name to value."
19+
),
20+
)
21+
22+
23+
class RowUpdateSpec(BaseModel):
24+
"""A row to update: id is required, extra keys are field name → new value."""
25+
26+
model_config = ConfigDict(extra="allow")
27+
28+
id: int = Field(..., description="The row ID.")
29+
30+
31+
class UpdateRowsInput(BaseModel):
32+
table_id: int = Field(..., description="The ID of the table containing the rows.")
33+
rows: list[RowUpdateSpec] = Field(
34+
...,
35+
description=(
36+
"List of rows to update. Each row must have 'id' "
37+
"plus the field names and their new values."
38+
),
39+
)
40+
41+
42+
class DeleteRowsInput(BaseModel):
43+
table_id: int = Field(..., description="The ID of the table to delete rows from.")
44+
row_ids: list[int] = Field(..., description="List of row IDs to delete.")

0 commit comments

Comments
 (0)